From 5f06839bea8a5d53cd66b43b76433980d987d2c5 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 26 Aug 2026 10:42:01 -0400 Subject: [PATCH 1/4] request-logger: fix Junie CLI support to use custom model profile, not proxy Junie's documented `proxies` entry in `config.json` (kind: "Anthropic"/ "OpenAI") looks right per JetBrains' published docs, but driving it against a real Junie CLI install (build 1543.24) showed it is never consulted -- Junie silently keeps using its normal authenticated backend regardless of `kind`, headless or interactive, with an empty logs folder and no error. Junie's separate Custom LLM models feature does work: a JSON profile under ~/.junie/models/*.json, selected explicitly with --model custom:. Verified end to end against a live proxy on both wire formats. The one catch is that a profile's baseUrl is the full endpoint path, not an origin, so the Anthropic and OpenAI provider entries now carry that suffix. Co-Authored-By: Claude Sonnet 5 --- request-logger/README.md | 41 +++++++++---- request-logger/agents.test.ts | 50 +++++++++++----- request-logger/agents.ts | 110 +++++++++++++++++++++------------- 3 files changed, 132 insertions(+), 69 deletions(-) diff --git a/request-logger/README.md b/request-logger/README.md index d32a239..50c5d86 100644 --- a/request-logger/README.md +++ b/request-logger/README.md @@ -141,14 +141,23 @@ is not wired up yet — ask for it via the issue tracker if you hit this. Junie is BYOK across several backends, with no fixed host of its own — the same shape as OMP — so it goes through the base URL and wire format questions like a custom target does, picking a real Anthropic-compatible or -OpenAI-compatible template depending which you choose. It writes a proxy -entry to `~/.junie/config.json` (user scope; a project-scope file at -`/.junie/config.json` takes precedence if you have one — see -Junie's own docs), merged in alongside whatever else is already there. +OpenAI-compatible template depending which you choose. It writes a **custom +model profile** to `~/.junie/models/request-logger.json`, and the command it +prints selects that profile explicitly with `--model custom:request-logger` +— Junie only uses a custom model when asked for by name. + +Junie's own docs also describe a different mechanism for this: a `proxies` +entry in `~/.junie/config.json` with a `kind` of `"Anthropic"` or `"OpenAI"`. +Do not use that instead. It was tried first, and driven against a real Junie +CLI install (build 1543.24) it does not work — Junie never consults it, +headless or interactive, no matter what `kind` is set to, and just keeps +talking to its normal authenticated backend with no error and an empty logs +folder. The custom-model route above is the one that was actually driven +end to end, with a real proxy in front of it, on both wire formats. Junie is the one agent here with no existing login for this tool to pass -through. Its custom-proxy mechanism bypasses JetBrains AI authentication -entirely, so the printed config carries a placeholder header line and you +through. Its custom-model mechanism bypasses JetBrains AI authentication +entirely, so the printed profile carries a placeholder header line and you paste in a real API key yourself — an Anthropic key on the Anthropic-compatible route, an OpenAI key on the OpenAI-compatible one. Treat that file the way you would any other file holding a real key: do not @@ -315,13 +324,19 @@ Be fair to the tool when you judge a failure. full course of the lesson. Claude Code on Google Vertex AI is in this group — verified against Anthropic's own Vertex documentation, not yet driven against a real Vertex project. -- **Junie is the least-verified entry in the catalogue.** Junie CLI is - closed source, so unlike every other agent here its entry was not checked - against real source, only against JetBrains' published Junie CLI docs - (`config.json`, custom proxies, and CLI reference). It has not been driven - against a real Junie install. If the proxy `kind`, the config file's - precise shape, or the header format is wrong, that is the likely reason, - and the fix is one line in `agents.ts`. +- **Junie was driven against a real install (build 1543.24)**, unlike every + other closed-source claim in this list. That test is exactly why this + entry no longer follows Junie's own documented `proxies`/`config.json` + mechanism: on that real install, it never took effect, regardless of + `kind`, with no error to say so. The custom-model route it uses instead + (`~/.junie/models/*.json` selected via `--model custom:`) was + confirmed to actually deliver requests to a local listener, on both wire + formats, with the correct endpoint path in each case. What is not verified + is everything upstream of that: Junie's task-completion behavior, its + interactive TUI, and whether a different install or a future release + changes any of this. If something here breaks, that is the likely reason, + and the fix is likely still one line in `agents.ts` — just possibly a + different line than the `kind` field this note used to point at. - **A custom base URL is only as tested as the agent it is attached to.** The base URL and wire format mechanism itself is tested directly (see `agents.test.ts`); a specific third-party server behind it has not diff --git a/request-logger/agents.test.ts b/request-logger/agents.test.ts index d121e60..4f1f295 100644 --- a/request-logger/agents.test.ts +++ b/request-logger/agents.test.ts @@ -1444,40 +1444,49 @@ describe("resolveChoice — Junie", () => { expect(result.kind).toBe("custom-target"); }); - it("gives Junie its own bin, with no env vars", () => { + it("tells Junie which custom model to use, with the endpoint path folded into the base URL", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", }); - expect(result.command).toBe("junie"); + expect(result.command).toBe("junie --model custom:request-logger"); + expect(result.baseUrl).toBe("http://localhost:8787/v1/messages"); }); - it("writes an Anthropic-kind proxy entry for the anthropic-compatible route", () => { + it("uses the OpenAI chat/completions path for the openai-compatible route", () => { + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "openai", + }); + expect(result.baseUrl).toBe("http://localhost:8787/v1/chat/completions"); + }); + + it("writes an Anthropic model profile for the anthropic-compatible route", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", }); expect(result.setup).toHaveLength(1); - expect(result.setup[0].path).toBe("~/.junie/config.json"); + expect(result.setup[0].path).toBe("~/.junie/models/request-logger.json"); expect(result.setup[0].language).toBe("json"); - expect(result.setup[0].body).toContain('"kind": "Anthropic"'); + expect(result.setup[0].body).toContain('"apiType": "Anthropic"'); expect(result.setup[0].body).toContain( - '"api-url": "http://localhost:8787"' + '"baseUrl": "http://localhost:8787/v1/messages"' ); - expect(result.setup[0].body).toContain("x-api-key:"); - expect(result.setup[0].body).toContain('"provider": "request-logger"'); + expect(result.setup[0].body).toContain('"x-api-key":'); }); - it("writes an OpenAI-kind proxy entry for the openai-compatible route", () => { + it("writes an OpenAICompletion model profile for the openai-compatible route", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", }); - expect(result.setup[0].body).toContain('"kind": "OpenAI"'); - expect(result.setup[0].body).toContain("Authorization: Bearer"); + expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); + expect(result.setup[0].body).toContain('"Authorization": "Bearer'); }); it("defaults Junie's raw/not-sure custom target to the OpenAI-kind template", () => { @@ -1489,7 +1498,7 @@ describe("resolveChoice — Junie", () => { customBaseUrl: "http://localhost:11434", customRenderer: "raw", }); - expect(result.setup[0].body).toContain('"kind": "OpenAI"'); + expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); }); it("never sends Junie's Anthropic-kind api-key header on the OpenAI route, or vice versa", () => { @@ -1503,11 +1512,11 @@ describe("resolveChoice — Junie", () => { customBaseUrl: "http://localhost:11434", customRenderer: "openai", }); - expect(anthropicRoute.setup[0].body).not.toContain("Authorization: Bearer"); - expect(openaiRoute.setup[0].body).not.toContain("x-api-key:"); + expect(anthropicRoute.setup[0].body).not.toContain('"Authorization":'); + expect(openaiRoute.setup[0].body).not.toContain('"x-api-key":'); }); - it("warns that Junie's proxy bypasses JetBrains AI authentication, unlike every login-based agent here", () => { + it("warns that Junie's model profile bypasses JetBrains AI authentication, unlike every login-based agent here", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", @@ -1516,6 +1525,17 @@ describe("resolveChoice — Junie", () => { expect(result.notes.some((note) => note.includes("bypasses"))).toBe(true); }); + it("warns that Junie's documented config.json proxy route does not actually work", () => { + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + }); + expect( + result.notes.some((note) => note.includes("never consulted")) + ).toBe(true); + }); + it("still resolves Junie even when the saved provider field is stale", () => { // alwaysCustom agents ignore whatever is saved under `provider`; only // the custom base URL and renderer matter. diff --git a/request-logger/agents.ts b/request-logger/agents.ts index 9687138..08f1487 100644 --- a/request-logger/agents.ts +++ b/request-logger/agents.ts @@ -287,37 +287,51 @@ const OMP_NOTE = "every OMP setup goes through the base URL and wire format you chose."; /** - * Junie's custom-proxy override, in JSON, under ~/.junie/config.json (user - * scope; see Junie's own configuration-files docs for how a project-scope - * file layers on top). Unlike ompModels/piModels, this one genuinely differs - * by wire format: Junie's proxy entry declares a `kind` — the protocol Junie - * itself will speak on the wire — so a mismatched kind does not 404, it just - * sends the wrong shape of request. That is why Junie gets two catalogue - * providers below (tagged by customTemplateFor) instead of OMP's one: the - * kind, and the auth header format that goes with it, must track the - * renderer the student actually picked. + * Junie's custom-model override, in JSON, under + * ~/.junie/models/request-logger.json (see Junie's own Custom LLM models + * docs). Junie also documents a *custom-proxy* mechanism — a "proxies" entry + * in ~/.junie/config.json with a `kind` of "Anthropic" or "OpenAI" — and an + * earlier version of this catalogue entry used that instead. It does not + * work: driven against a real Junie CLI install (build 1543.24), that + * config.json entry is never consulted, headless or interactive, regardless + * of `kind` — Junie silently keeps using its normal authenticated backend + * (JetBrains AI, or whatever BYOK key it already had) and nothing reaches + * this tool. The custom-model route below is a different mechanism that was + * verified to actually work the same way: a JSON profile Junie discovers + * from `~/.junie/models/*.json` and that must be selected explicitly with + * `--model custom:request-logger` (see the Junie ProviderEntry below). * - * `authHeader` is a placeholder line, not a real credential — see JUNIE_NOTE. + * Unlike the proxy route's `api-url`, a model profile's `baseUrl` is the + * *full* endpoint path, not an origin — Junie posts to it verbatim with no + * path appended. Getting the suffix right (`/v1/messages` for Anthropic, + * `/v1/chat/completions` for OpenAI — see each ProviderEntry.suffix below) + * is what stands between a working capture and a silent 404. Confirmed by + * driving both wire formats against a live proxy. + * + * `authHeaderValue` is a placeholder line, not a real credential — see + * JUNIE_NOTE. */ -function junieConfig( +function junieModelConfig( baseUrl: string, - kind: "Anthropic" | "OpenAI", - authHeader: string + apiType: "Anthropic" | "OpenAICompletion", + authHeaderName: string, + authHeaderValue: string ): SetupFile { return { - path: "~/.junie/config.json", + path: "~/.junie/models/request-logger.json", language: "json", body: [ "{", - ' "proxies": [', - " {", - ' "name": "request-logger",', - ` "kind": "${kind}",`, - ` "api-url": "${baseUrl}",`, - ` "headers": ["${authHeader}"]`, - " }", - " ],", - ' "provider": "request-logger"', + ` "baseUrl": "${baseUrl}",`, + ' "id": "request-logger",', + ` "apiType": "${apiType}",`, + ' "extraHeaders": {', + ` "${authHeaderName}": "${authHeaderValue}"` + + (apiType === "Anthropic" ? "," : ""), + ...(apiType === "Anthropic" + ? [' "anthropic-version": "2023-06-01"'] + : []), + " }", "}", ].join("\n"), }; @@ -325,15 +339,17 @@ function junieConfig( const JUNIE_NOTE = "Junie has no existing login for this tool to pass through the way Claude " + - "Code or Codex do. Its custom proxy bypasses JetBrains AI authentication " + + "Code or Codex do. This model profile bypasses JetBrains AI authentication " + "entirely, so a real API key for whichever backend you are logging goes " + - "straight into config.json's headers array in plaintext. Do not commit " + - "this file."; + "straight into the file in plaintext. Do not commit it."; -const JUNIE_MERGE_NOTE = - "This is merged into ~/.junie/config.json, not a replacement for it — add " + - "the proxies entry and the provider key alongside whatever else is " + - "already in that file."; +const JUNIE_PROXY_DOES_NOT_WORK_NOTE = + "Junie's docs also describe a \"proxies\" entry in ~/.junie/config.json " + + "for this. Do not use it instead of the file below: verified against a " + + "real Junie CLI install (build 1543.24), that config is never consulted " + + "— Junie keeps using its normal authenticated backend no matter what " + + "`kind` is set to, with no error and an empty logs folder. The model " + + "profile below is the mechanism that was actually driven end to end."; const OPENCODE_NOTE = "The environment variable above works, but only by accident: OpenCode passes " + @@ -714,10 +730,13 @@ const AGENTS: AgentEntry[] = [ // own — the same shape as OMP — so every setup for it is custom too. See // AgentEntry.alwaysCustom. // - // Junie CLI is closed source, so unlike every other entry in this - // catalogue this one is verified against JetBrains' published docs only, - // not against real source or a real install. Testing status is recorded - // in the README's "How much this was tested" section, the same way every + // Junie CLI is closed source, so most of this catalogue's other entries + // cannot be checked against real source the way, say, Claude Code's can. + // This entry is the one exception that was actually driven against a + // real Junie CLI install (build 1543.24) rather than only against + // JetBrains' published docs — see junieModelConfig's comment above for + // what that testing found. Overall testing status is still recorded in + // the README's "How much this was tested" section, the same way every // other agent's is; it is not printed to the student, the same way no // other agent's is either. alwaysCustom: true, @@ -729,16 +748,22 @@ const AGENTS: AgentEntry[] = [ // path that would read this. See resolveCustomTarget. upstreamHost: "", renderer: "raw", + // A model profile's baseUrl is the full endpoint path, not an + // origin — see junieModelConfig's comment. + suffix: "/v1/messages", bin: "junie", + // Junie only looks at a custom model when it is asked for by name. + args: ["--model", "custom:request-logger"], customTemplateFor: ["anthropic"], setup: [ - junieConfig( + junieModelConfig( "{baseUrl}", "Anthropic", - "x-api-key: YOUR_ANTHROPIC_API_KEY" + "x-api-key", + "YOUR_ANTHROPIC_API_KEY" ), ], - notes: [JUNIE_NOTE, JUNIE_MERGE_NOTE], + notes: [JUNIE_NOTE, JUNIE_PROXY_DOES_NOT_WORK_NOTE], }, { id: "openai", @@ -748,19 +773,22 @@ const AGENTS: AgentEntry[] = [ // this. See resolveCustomTarget. upstreamHost: "", renderer: "raw", + suffix: "/v1/chat/completions", bin: "junie", + args: ["--model", "custom:request-logger"], // Also the catch-all for "raw"/not sure — see the OpenCode and Pi // entries above for why an OpenAI-compatible guess is the better // default for an unidentified custom server. customTemplateFor: ["openai", "raw"], setup: [ - junieConfig( + junieModelConfig( "{baseUrl}", - "OpenAI", - "Authorization: Bearer YOUR_OPENAI_API_KEY" + "OpenAICompletion", + "Authorization", + "Bearer YOUR_OPENAI_API_KEY" ), ], - notes: [JUNIE_NOTE, JUNIE_MERGE_NOTE], + notes: [JUNIE_NOTE, JUNIE_PROXY_DOES_NOT_WORK_NOTE], }, ], }, From 4e33f223e21219d15169fedc964c548ecec0f8e6 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 26 Aug 2026 10:54:14 -0400 Subject: [PATCH 2/4] request-logger: surface the real reason a connection to upstream failed Node throws an AggregateError when a connection to a dual-stack host (e.g. "localhost") fails on every address it tries -- its own .message is always "", with the real per-address reasons nested in .errors. The upstream error handler was printing err.message directly, so every failed connection to upstream showed up as a bare "[request-logger] upstream error:" with nothing after the colon and no way to tell what was actually wrong. Also route these errors through the existing burst guard: a dead upstream fails every retry identically and fast, the same shape of storm the guard already exists for on the success path, but the connection-error path had never been wired into it. Co-Authored-By: Claude Sonnet 5 --- request-logger/proxy.test.ts | 37 ++++++++++++++++++++++++++++ request-logger/proxy.ts | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/request-logger/proxy.test.ts b/request-logger/proxy.test.ts index b3fb492..520ce9b 100644 --- a/request-logger/proxy.test.ts +++ b/request-logger/proxy.test.ts @@ -6,6 +6,7 @@ import { BURST_THRESHOLD, BURST_WINDOW_MS, burstKey, + describeError, rejectUpgrade, trackBurst, upstreamConnection, @@ -128,6 +129,42 @@ describe("upstreamConnection", () => { }); }); +describe("describeError", () => { + it("uses the message on a plain Error", () => { + expect(describeError(new Error("connect ECONNREFUSED 127.0.0.1:8787"))).toBe( + "connect ECONNREFUSED 127.0.0.1:8787" + ); + }); + + it("falls back to the error code when the message is blank", () => { + const err = new Error(""); + (err as NodeJS.ErrnoException).code = "ECONNREFUSED"; + expect(describeError(err)).toBe("ECONNREFUSED"); + }); + + it("unpacks an AggregateError instead of printing its always-blank message", () => { + // Node throws exactly this shape connecting to a dual-stack host (e.g. + // "localhost") with nothing listening: the outer AggregateError.message + // is "", and the real reasons are one level down in .errors. This is + // the bug that made every failed upstream connection print + // "[request-logger] upstream error:" with nothing after the colon. + const err = new AggregateError( + [ + new Error("connect ECONNREFUSED 127.0.0.1:59999"), + new Error("connect ECONNREFUSED ::1:59999"), + ], + "" + ); + expect(describeError(err)).toBe( + "connect ECONNREFUSED 127.0.0.1:59999; connect ECONNREFUSED ::1:59999" + ); + }); + + it("falls back to String() for a non-Error throw", () => { + expect(describeError("socket hang up")).toBe("socket hang up"); + }); +}); + describe("rejectUpgrade", () => { let server: http.Server | undefined; diff --git a/request-logger/proxy.ts b/request-logger/proxy.ts index d3f4f62..6cb95f9 100644 --- a/request-logger/proxy.ts +++ b/request-logger/proxy.ts @@ -85,6 +85,30 @@ export function upstreamConnection(target: ProxyTarget): { }; } +/** + * A readable description of a failed upstream connection. + * + * Node's http/https client resolves `localhost` (and any other dual-stack + * host) by trying IPv4 and IPv6 in parallel. When both attempts fail — most + * commonly because nothing is listening at the configured upstream at all — + * Node throws an AggregateError whose own `.message` is always the empty + * string; the real reasons live one level down, in `.errors`. Printing + * `err.message` directly on that error prints nothing, which is exactly what + * was happening here: a student watching a wall of blank + * "[request-logger] upstream error:" lines with no way to tell what's wrong. + * This reaches into `.errors` (and falls back to `.code`, then `.message`) + * so the printed line always says why the connection actually failed. + */ +export function describeError(err: unknown): string { + if (err instanceof AggregateError && err.errors.length > 0) { + return err.errors.map((e) => describeError(e)).join("; "); + } + if (err instanceof Error) { + return err.message || (err as NodeJS.ErrnoException).code || err.name; + } + return String(err); +} + /** * Headers forwarded upstream. We strip hop-by-hop headers, and we ask for an * uncompressed response so the capture is readable, then recompute the length @@ -166,12 +190,31 @@ function handle( : http.request(requestOptions, onUpstreamResponse); upstreamReq.on("error", (err) => { - console.error(`[request-logger] upstream error: ${err.message}`); + const description = describeError(err); + // A dead upstream fails every retry identically and fast, the same + // shape of storm BURST_THRESHOLD exists for below — so this shares + // that guard rather than flooding the console on its own. There is no + // real status code here (the connection never got that far), so 0 + // stands in for one: burstKey only uses it to tell captures apart. + const burst = trackBurst( + burstState, + burstKey(req.method ?? "POST", reqPath, 0), + Date.now() + ); + burstState = burst.state; + if (!burst.suppressed) { + console.error(`[request-logger] upstream error: ${description}`); + } else if (burst.justDetected) { + console.error( + `[request-logger] upstream error: ${description} ` + + `(repeating fast — further failures of this exact call are now suppressed)` + ); + } if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); } res.end( - JSON.stringify({ error: `request-logger upstream error: ${err.message}` }) + JSON.stringify({ error: `request-logger upstream error: ${description}` }) ); }); From b87cce8b5b977c6474f5f22eb708954c9c83f25c Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 26 Aug 2026 11:00:38 -0400 Subject: [PATCH 3/4] request-logger: stop setting anthropic-version in Junie's model profile Junie's Anthropic client always sends its own anthropic-version header. extraHeaders does not replace an existing header of the same name, it adds a second occurrence, and the two get joined with a comma on the wire (anthropic-version: 2023-06-01,2023-06-01) -- which Anthropic rejects as an invalid version code. Every request was landing at request-logger as a 400 because of this, even with a valid key. Confirmed live: dropping the header here produces a clean single anthropic-version: 2023-06-01 on the wire, and the request reaches Anthropic far enough to get a real 401 on a bad key instead of a 400 on the header. Co-Authored-By: Claude Sonnet 5 --- request-logger/agents.test.ts | 15 +++++++++++++++ request-logger/agents.ts | 17 ++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/request-logger/agents.test.ts b/request-logger/agents.test.ts index 4f1f295..0bce429 100644 --- a/request-logger/agents.test.ts +++ b/request-logger/agents.test.ts @@ -1479,6 +1479,21 @@ describe("resolveChoice — Junie", () => { expect(result.setup[0].body).toContain('"x-api-key":'); }); + it("does not set anthropic-version itself, since Junie already sends one", () => { + // Junie's Anthropic client always sends its own anthropic-version + // header. An extraHeaders entry with the same name does not replace + // that, it is appended as a second occurrence, and the two get joined + // with a comma on the wire (e.g. "2023-06-01,2023-06-01") -- which + // Anthropic rejects as an invalid version code. Confirmed against a + // live proxy: this is what was producing a 400 on every request. + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + }); + expect(result.setup[0].body).not.toContain("anthropic-version"); + }); + it("writes an OpenAICompletion model profile for the openai-compatible route", () => { const result = customTarget({ agent: "junie", diff --git a/request-logger/agents.ts b/request-logger/agents.ts index 08f1487..bb2090d 100644 --- a/request-logger/agents.ts +++ b/request-logger/agents.ts @@ -308,6 +308,17 @@ const OMP_NOTE = * is what stands between a working capture and a silent 404. Confirmed by * driving both wire formats against a live proxy. * + * `extraHeaders` holds only the auth header. An earlier version also added + * `anthropic-version` here, on the assumption that Junie's Anthropic client + * would not set it itself. It does — Junie always sends its own + * `anthropic-version: 2023-06-01`, and an `extraHeaders` entry does not + * replace that, it is appended as a second occurrence of the same header + * name. The two get joined with a comma on the wire + * (`anthropic-version: 2023-06-01,2023-06-01`), and Anthropic rejects that + * as an invalid version code — a 400 from a request that looks, at a + * glance, correctly authenticated. Confirmed against a live proxy: removing + * the header here is what fixed it, so nothing here should re-add it. + * * `authHeaderValue` is a placeholder line, not a real credential — see * JUNIE_NOTE. */ @@ -326,11 +337,7 @@ function junieModelConfig( ' "id": "request-logger",', ` "apiType": "${apiType}",`, ' "extraHeaders": {', - ` "${authHeaderName}": "${authHeaderValue}"` + - (apiType === "Anthropic" ? "," : ""), - ...(apiType === "Anthropic" - ? [' "anthropic-version": "2023-06-01"'] - : []), + ` "${authHeaderName}": "${authHeaderValue}"`, " }", "}", ].join("\n"), From 1c800094a77dbdac159bace72d2a6f7495ad29fd Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 26 Aug 2026 11:24:24 -0400 Subject: [PATCH 4/4] request-logger: send a real model ID in Junie's model profile, not a placeholder Junie sends a model profile's "id" field verbatim as the "model" field of every request. The profile hard-coded id to "request-logger", which is not a real model on any backend, so Anthropic rejected every request with a 404 ("model: request-logger"). Confirmed by reading Junie's own bundled docs (Custom-LLM-models.md, from the installed CLI's jar) and by driving a live proxy end to end. Junie's custom target now asks for a real model ID the same way Pi's and OpenCode's custom-OpenAI targets already do, and writes it into the profile's id field. Verified live: the proxy's captured request now shows the chosen model, and Anthropic returns 401 (invalid key) instead of 404. Co-Authored-By: Claude Sonnet 5 --- request-logger/README.md | 25 ++++++++++++++++----- request-logger/agents.test.ts | 42 +++++++++++++++++++++++++++++++++-- request-logger/agents.ts | 34 ++++++++++++++++++++++++++-- request-logger/config.ts | 14 ++++++++---- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/request-logger/README.md b/request-logger/README.md index 50c5d86..021ad5e 100644 --- a/request-logger/README.md +++ b/request-logger/README.md @@ -42,11 +42,12 @@ If you are not sure, pick "not sure". The tool still logs everything; it just shows you the raw JSON instead of a fully readable render, because it cannot safely guess a shape you did not tell it. See "What you get" below. -For **OpenCode** with an **OpenAI-compatible** custom target, and for **every** -**Pi** custom target, the wizard also tries the unauthenticated `/v1/models` -endpoint and offers any model IDs it finds. You can always enter an ID -manually, and the wizard falls back to manual entry if discovery fails. -Endpoints that require credentials for model listing are not supported. +For **OpenCode** with an **OpenAI-compatible** custom target, for **every** +**Pi** custom target, and for **every Junie** target, the wizard also tries +the unauthenticated `/v1/models` endpoint and offers any model IDs it finds. +You can always enter an ID manually, and the wizard falls back to manual +entry if discovery fails. Endpoints that require credentials for model +listing are not supported. - OpenCode's printed command uses a temporary `OPENCODE_CONFIG_CONTENT` provider for that one run, merged with your existing OpenCode configuration @@ -62,6 +63,10 @@ Endpoints that require credentials for model listing are not supported. endpoint, which an Anthropic-compatible server often does not expose. If discovery finds nothing there, that is expected, and typing the model ID by hand is the normal path, not a sign something is broken. +- Junie asks for a model on every wire format too, for a different reason + than Pi: Junie sends the model profile's `id` verbatim as the `model` field + of every request, so it has to be a real model your backend recognizes, not + a placeholder — see the Junie section below. To change a remembered answer: @@ -139,13 +144,21 @@ is not wired up yet — ask for it via the issue tracker if you hit this. ### Junie Junie is BYOK across several backends, with no fixed host of its own — the -same shape as OMP — so it goes through the base URL and wire format +same shape as OMP — so it goes through the base URL, wire format and model questions like a custom target does, picking a real Anthropic-compatible or OpenAI-compatible template depending which you choose. It writes a **custom model profile** to `~/.junie/models/request-logger.json`, and the command it prints selects that profile explicitly with `--model custom:request-logger` — Junie only uses a custom model when asked for by name. +The model you pick goes into the profile's `id` field, which Junie sends +verbatim as the `model` field of every request — it is not a label for this +tool to key off of. An earlier version of this profile hard-coded `id` to +`"request-logger"`, which is not a real model on any backend: Anthropic +rejected every request with a 404 (`model: request-logger`), confirmed +against a live proxy. Pick a model your backend actually serves (e.g. +`claude-sonnet-4-5` against the real Anthropic API). + Junie's own docs also describe a different mechanism for this: a `proxies` entry in `~/.junie/config.json` with a `kind` of `"Anthropic"` or `"OpenAI"`. Do not use that instead. It was tried first, and driven against a real Junie diff --git a/request-logger/agents.test.ts b/request-logger/agents.test.ts index 0bce429..61916fe 100644 --- a/request-logger/agents.test.ts +++ b/request-logger/agents.test.ts @@ -1432,12 +1432,13 @@ describe("resolveChoice — OMP", () => { // --------------------------------------------------------------------------- describe("resolveChoice — Junie", () => { - it("resolves Junie straight from a base URL and wire format, with no provider needed", () => { + it("resolves Junie straight from a base URL, wire format and model, with no provider needed", () => { const result = resolveChoice( { agent: "junie", customBaseUrl: "http://localhost:8787", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }, PORT ); @@ -1449,6 +1450,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.command).toBe("junie --model custom:request-logger"); expect(result.baseUrl).toBe("http://localhost:8787/v1/messages"); @@ -1459,6 +1461,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", + customModel: "gpt-4.1", }); expect(result.baseUrl).toBe("http://localhost:8787/v1/chat/completions"); }); @@ -1468,6 +1471,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.setup).toHaveLength(1); expect(result.setup[0].path).toBe("~/.junie/models/request-logger.json"); @@ -1479,6 +1483,32 @@ describe("resolveChoice — Junie", () => { expect(result.setup[0].body).toContain('"x-api-key":'); }); + it("writes the chosen model into the profile's id field, since Junie sends it verbatim as the model on every request", () => { + // An earlier version hard-coded "id" to "request-logger", which is not a + // real model on any backend -- Anthropic rejected it with a 404 ("model: + // request-logger"), confirmed against a live proxy. + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", + }); + expect(result.setup[0].body).toContain('"id": "claude-sonnet-4-5"'); + expect(result.setup[0].body).not.toContain("request-logger"); + }); + + it("rejects a Junie custom target with no model chosen", () => { + const result = resolveChoice( + { + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + }, + PORT + ); + expect(result.kind).toBe("error"); + }); + it("does not set anthropic-version itself, since Junie already sends one", () => { // Junie's Anthropic client always sends its own anthropic-version // header. An extraHeaders entry with the same name does not replace @@ -1490,6 +1520,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.setup[0].body).not.toContain("anthropic-version"); }); @@ -1499,6 +1530,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", + customModel: "gpt-4.1", }); expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); expect(result.setup[0].body).toContain('"Authorization": "Bearer'); @@ -1512,6 +1544,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "raw", + customModel: "some-model", }); expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); }); @@ -1521,11 +1554,13 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); const openaiRoute = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", + customModel: "gpt-4.1", }); expect(anthropicRoute.setup[0].body).not.toContain('"Authorization":'); expect(openaiRoute.setup[0].body).not.toContain('"x-api-key":'); @@ -1536,6 +1571,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.notes.some((note) => note.includes("bypasses"))).toBe(true); }); @@ -1545,6 +1581,7 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect( result.notes.some((note) => note.includes("never consulted")) @@ -1553,13 +1590,14 @@ describe("resolveChoice — Junie", () => { it("still resolves Junie even when the saved provider field is stale", () => { // alwaysCustom agents ignore whatever is saved under `provider`; only - // the custom base URL and renderer matter. + // the custom base URL, renderer and model matter. const result = resolveChoice( { agent: "junie", provider: "whatever-was-saved-before", customBaseUrl: "http://localhost:11434", customRenderer: "raw", + customModel: "some-model", }, PORT ); diff --git a/request-logger/agents.ts b/request-logger/agents.ts index bb2090d..fe97605 100644 --- a/request-logger/agents.ts +++ b/request-logger/agents.ts @@ -321,9 +321,19 @@ const OMP_NOTE = * * `authHeaderValue` is a placeholder line, not a real credential — see * JUNIE_NOTE. + * + * `id` is not a label for this tool — Junie sends it verbatim as the + * `model` field of every request it makes on this profile. An earlier + * version hard-coded it to `"request-logger"`, which is not a model any + * real backend recognizes: Anthropic rejected it with a 404 ("model: + * request-logger"), confirmed against a live proxy. It must be a real + * model ID for whichever backend `baseUrl` points at, which is why + * customTargetNeedsModel (below) asks the student for one instead of this + * function inventing a name. */ function junieModelConfig( baseUrl: string, + model: string, apiType: "Anthropic" | "OpenAICompletion", authHeaderName: string, authHeaderValue: string @@ -334,7 +344,7 @@ function junieModelConfig( body: [ "{", ` "baseUrl": "${baseUrl}",`, - ' "id": "request-logger",', + ` "id": "${model}",`, ` "apiType": "${apiType}",`, ' "extraHeaders": {', ` "${authHeaderName}": "${authHeaderValue}"`, @@ -765,6 +775,7 @@ const AGENTS: AgentEntry[] = [ setup: [ junieModelConfig( "{baseUrl}", + "{model}", "Anthropic", "x-api-key", "YOUR_ANTHROPIC_API_KEY" @@ -790,6 +801,7 @@ const AGENTS: AgentEntry[] = [ setup: [ junieModelConfig( "{baseUrl}", + "{model}", "OpenAICompletion", "Authorization", "Bearer YOUR_OPENAI_API_KEY" @@ -1059,12 +1071,17 @@ const RAW_WIRE_FORMAT_NOTE = * an existing built-in provider (openai or anthropic), and that * provider's built-in model names almost never exist on a self-hosted * backend, whichever wire format was chosen for rendering. + * - Junie needs one on every route: a model profile's `id` is not a label, + * Junie sends it verbatim as every request's `model` field (see + * junieModelConfig), so an invented placeholder there is rejected by the + * real backend with a 404 — confirmed against a live proxy. */ export function customTargetNeedsModel( agentId: string, renderer: RendererId ): boolean { if (agentId === "pi") return true; + if (agentId === "junie") return true; if (agentId === "opencode") return renderer === "openai"; return false; } @@ -1237,6 +1254,17 @@ function resolveCustomTarget( ]; if (renderer === "raw") notes.push(RAW_WIRE_FORMAT_NOTE); + // Junie is the only agent that reaches this generic branch and also needs + // a model declared up front — its setup file's {model} placeholder is + // filled in below the same way {baseUrl} always has been. See + // customTargetNeedsModel and junieModelConfig for why. + let model: string | undefined; + if (customTargetNeedsModel(agent.id, renderer)) { + const modelResult = resolveCustomModel(choice, `${agent.label}'s custom target`); + if (modelResult.kind === "error") return modelResult; + model = modelResult.model; + } + return { kind: "custom-target", agent: agent.id, @@ -1248,7 +1276,9 @@ function resolveCustomTarget( command: template ? buildCommand(template, baseUrl, platform) : agent.id, setup: (template?.setup ?? []).map((file) => ({ ...file, - body: file.body.replace(/\{baseUrl\}/g, baseUrl), + body: file.body + .replace(/\{baseUrl\}/g, baseUrl) + .replace(/\{model\}/g, model ?? ""), })), notes, warnings: template?.warnings ?? [], diff --git a/request-logger/config.ts b/request-logger/config.ts index c54b25e..857891e 100644 --- a/request-logger/config.ts +++ b/request-logger/config.ts @@ -178,9 +178,10 @@ async function askModelIdManually(): Promise { /** * Discover the model IDs a custom base URL serves, and ask which one to use * when there is a real choice — shared by every custom target that needs a - * model declared up front: OpenCode's custom OpenAI-compatible route, and - * every one of Pi's custom routes. Same shape both times: discover, offer a - * choice when there is more than one candidate, fall back to manual entry. + * model declared up front: OpenCode's custom OpenAI-compatible route, every + * one of Pi's custom routes, and every one of Junie's. Same shape every + * time: discover, offer a choice when there is more than one candidate, + * fall back to manual entry. */ async function askDiscoveredModel(baseUrl: string): Promise { const modelIds = await discoverModelIds(baseUrl); @@ -255,13 +256,18 @@ export async function askChoice(options: AskOptions): Promise { if (agent?.alwaysCustom) { // Every setup for this agent is a custom target — there is no fixed // provider to fall back to, so the provider question is skipped and the - // two custom questions are asked directly instead. + // custom questions are asked directly instead: base URL and wire format + // always, plus a model when customTargetNeedsModel says this agent + // needs one declared up front (Junie does, on every route). const custom = await askCustomTarget(); choice = { agent: agentId, provider: CUSTOM_ID, customBaseUrl: custom.baseUrl, customRenderer: custom.renderer, + customModel: customTargetNeedsModel(agentId, custom.renderer) + ? await askDiscoveredModel(custom.baseUrl) + : undefined, }; } else { // Every supported agent asks a provider question now, even one with a