diff --git a/README.md b/README.md index 0d36c13c9..f763f7b99 100644 --- a/README.md +++ b/README.md @@ -151,18 +151,19 @@ Copilot API now uses a subcommand structure with these main commands: The following command line options are available for the `start` command: -| Option | Description | Default | Alias | -| -------------- | ----------------------------------------------------------------------------- | ---------- | ----- | -| --port | Port to listen on | 4141 | -p | -| --verbose | Enable verbose logging | false | -v | -| --account-type | Account type to use (individual, business, enterprise) | individual | -a | -| --manual | Enable manual request approval | false | none | -| --rate-limit | Rate limit in seconds between requests | none | -r | -| --wait | Wait instead of error when rate limit is hit | false | -w | -| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g | -| --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c | -| --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | -| --proxy-env | Initialize proxy from environment variables | false | none | +| Option | Description | Default | Alias | +| -------------------- | ----------------------------------------------------------------------------- | ---------- | ----- | +| --port | Port to listen on | 4141 | -p | +| --verbose | Enable verbose logging | false | -v | +| --account-type | Account type to use (individual, business, enterprise) | individual | -a | +| --manual | Enable manual request approval | false | none | +| --rate-limit | Rate limit in seconds between requests | none | -r | +| --wait | Wait instead of error when rate limit is hit | false | -w | +| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g | +| --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c | +| --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | +| --proxy-env | Initialize proxy from environment variables | false | none | +| --no-native-messages | Translate Claude models via /chat/completions instead of native /v1/messages | false | none | ### Auth Command Options diff --git a/src/lib/state.ts b/src/lib/state.ts index 5ba4dc1d1..baff5f370 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -11,6 +11,7 @@ export interface State { manualApprove: boolean rateLimitWait: boolean showToken: boolean + nativeMessages: boolean // Rate limiting configuration rateLimitSeconds?: number @@ -22,4 +23,5 @@ export const state: State = { manualApprove: false, rateLimitWait: false, showToken: false, + nativeMessages: true, } diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc8..c556e11b6 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -19,8 +19,13 @@ export interface AnthropicMessagesPayload { name?: string } thinking?: { - type: "enabled" + // "enabled", "adaptive", "disabled", "between_tools", depending on model + type: string budget_tokens?: number + display?: string + } + output_config?: { + effort?: string } service_tier?: "auto" | "standard_only" } diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 85dbf6243..18a8a3fc1 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -16,6 +16,10 @@ import { type AnthropicMessagesPayload, type AnthropicStreamState, } from "./anthropic-types" +import { + handleNativeCompletion, + shouldUseNativeMessages, +} from "./native-handler" import { translateToAnthropic, translateToOpenAI, @@ -28,16 +32,20 @@ export async function handleCompletion(c: Context) { const anthropicPayload = await c.req.json() consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload)) + if (state.manualApprove) { + await awaitApproval() + } + + if (shouldUseNativeMessages(anthropicPayload)) { + return handleNativeCompletion(anthropicPayload, c.req.raw.headers) + } + const openAIPayload = translateToOpenAI(anthropicPayload) consola.debug( "Translated OpenAI request payload:", JSON.stringify(openAIPayload), ) - if (state.manualApprove) { - await awaitApproval() - } - const response = await createChatCompletions(openAIPayload) if (isNonStreaming(response)) { diff --git a/src/routes/messages/native-handler.ts b/src/routes/messages/native-handler.ts new file mode 100644 index 000000000..e3e6d3ce6 --- /dev/null +++ b/src/routes/messages/native-handler.ts @@ -0,0 +1,64 @@ +import { HTTPError } from "~/lib/error" +import { state } from "~/lib/state" +import { + createNativeMessages, + type NativeMessagesPayload, +} from "~/services/copilot/create-native-messages" + +const FORWARDED_REQUEST_HEADERS = ["anthropic-version", "anthropic-beta"] +// retry-after and x-should-retry are the retry signals that Anthropic SDKs read. +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "cache-control", + "retry-after", + "x-should-retry", +] + +// Copilot's /v1/messages rejects a trailing assistant message (prefill) for +// current Claude models, so these requests keep the translation path. +export function shouldUseNativeMessages( + payload: NativeMessagesPayload, +): boolean { + return ( + state.nativeMessages + && payload.model.startsWith("claude") + && payload.messages.at(-1)?.role !== "assistant" + ) +} + +function pickHeaders( + headers: Headers, + names: Array, +): Record { + const picked: Record = {} + for (const name of names) { + const value = headers.get(name) + if (value) picked[name] = value + } + return picked +} + +function toClientResponse(upstream: Response): Response { + return new Response(upstream.body, { + status: upstream.status, + headers: pickHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS), + }) +} + +export async function handleNativeCompletion( + payload: NativeMessagesPayload, + clientHeaders: Headers, +): Promise { + try { + const upstream = await createNativeMessages( + payload, + pickHeaders(clientHeaders, FORWARDED_REQUEST_HEADERS), + ) + return toClientResponse(upstream) + } catch (error) { + // Upstream errors keep their status, body and retry-after so that the + // client can decide whether to retry. + if (error instanceof HTTPError) return toClientResponse(error.response) + throw error + } +} diff --git a/src/services/copilot/create-native-messages.ts b/src/services/copilot/create-native-messages.ts new file mode 100644 index 000000000..059084f6e --- /dev/null +++ b/src/services/copilot/create-native-messages.ts @@ -0,0 +1,124 @@ +import consola from "consola" + +import { copilotBaseUrl, copilotHeaders } from "~/lib/api-config" +import { HTTPError } from "~/lib/error" +import { state } from "~/lib/state" + +import { thinkingTypeReplacements } from "./thinking-type" + +// Sends Anthropic Messages requests to Copilot's native /v1/messages endpoint +// without translation, because the /chat/completions translation drops +// `thinking` and `output_config.effort`. Only a thinking type that Copilot +// rejects is changed, in a copy of the payload. + +export interface NativeMessagesPayload { + model: string + messages: Array<{ role: string; content: unknown }> + thinking?: { type: string; display?: string } + output_config?: { effort?: string } +} + +// Also looks inside nested content, e.g. images in tool_result blocks. +function contentHasBlock(content: unknown, type: string): boolean { + return ( + Array.isArray(content) + && content.some( + (block: unknown) => + typeof block === "object" + && block !== null + && "type" in block + && (block.type === type + || ("content" in block && contentHasBlock(block.content, type))), + ) + ) +} + +function payloadHasBlock( + payload: NativeMessagesPayload, + type: string, +): boolean { + return payload.messages.some((message) => + contentHasBlock(message.content, type), + ) +} + +function isAgentCall(payload: NativeMessagesPayload): boolean { + return ( + payload.messages.some((message) => message.role === "assistant") + || payloadHasBlock(payload, "tool_result") + ) +} + +function withThinkingType( + payload: NativeMessagesPayload, + type: string, +): NativeMessagesPayload { + if (payload.thinking?.type === type) return payload + const display = payload.thinking?.display + return { ...payload, thinking: { type, ...(display ? { display } : {}) } } +} + +function requestSummary(payload: NativeMessagesPayload): string { + return ( + `model=${payload.model}` + + ` thinking=${JSON.stringify(payload.thinking ?? null)}` + + ` effort=${JSON.stringify(payload.output_config?.effort ?? null)}` + ) +} + +function postMessages( + payload: NativeMessagesPayload, + anthropicHeaders: Record, +): Promise { + const headers: Record = { + ...copilotHeaders(state, payloadHasBlock(payload, "image")), + "X-Initiator": isAgentCall(payload) ? "agent" : "user", + "anthropic-version": "2023-06-01", + ...anthropicHeaders, + } + return fetch(`${copilotBaseUrl(state)}/v1/messages`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) +} + +// `anthropicHeaders` holds the client's anthropic-version and anthropic-beta. +export async function createNativeMessages( + payload: NativeMessagesPayload, + anthropicHeaders: Record, +): Promise { + if (!state.copilotToken) throw new Error("Copilot token not found") + + const requested = payload.thinking?.type + const request = requested ? { model: payload.model, requested } : undefined + const sent = + request ? thinkingTypeReplacements.typeToSend(request) : undefined + let body = sent ? withThinkingType(payload, sent) : payload + consola.info(`Native /v1/messages: ${requestSummary(body)}`) + let response = await postMessages(body, anthropicHeaders) + + const retryType = + response.status === 400 && request && sent ? + thinkingTypeReplacements.retryType( + request, + sent, + await response.clone().text(), + ) + : undefined + if (request && retryType) { + body = withThinkingType(payload, retryType) + consola.info(`Native /v1/messages: retry: ${requestSummary(body)}`) + response = await postMessages(body, anthropicHeaders) + if (response.ok) thinkingTypeReplacements.remember(request, retryType) + } + + if (!response.ok) { + consola.error( + `Failed to create native messages: HTTP ${response.status}`, + await response.clone().text(), + ) + throw new HTTPError("Failed to create native messages", response) + } + return response +} diff --git a/src/services/copilot/thinking-type.ts b/src/services/copilot/thinking-type.ts new file mode 100644 index 000000000..fdd02b6eb --- /dev/null +++ b/src/services/copilot/thinking-type.ts @@ -0,0 +1,81 @@ +// Copilot rejects some thinking types per model with HTTP 400 and names the +// type that it accepts, in one of two message shapes: +// "thinking.type.disabled" is not supported ... Use "thinking.type.adaptive" ... +// send "thinking": {"type": "between_tools"} instead of {"type": "disabled"} +const REPLACEMENT_PATTERN = + /send "thinking": \{"type": "(?[a-z_]+)"\} instead of \{"type": "(?[a-z_]+)"\}/ +const REJECTED_PATTERN = + /"thinking\.type\.(?[a-z_]+)" is not supported/ +const ACCEPTED_PATTERN = /Use "thinking\.type\.(?[a-z_]+)"/ + +export interface ThinkingRejection { + rejected: string + accepted: string | undefined +} + +function readErrorMessage(errorBody: string): string { + try { + const parsed = JSON.parse(errorBody) as { error?: { message?: unknown } } + const message = parsed.error?.message + return typeof message === "string" ? message : errorBody + } catch { + return errorBody + } +} + +export function parseThinkingRejection( + errorBody: string, +): ThinkingRejection | undefined { + const message = readErrorMessage(errorBody) + const replacement = REPLACEMENT_PATTERN.exec(message)?.groups + const rejected = + replacement?.rejected ?? REJECTED_PATTERN.exec(message)?.groups?.rejected + if (!rejected) return undefined + const accepted = + replacement?.accepted ?? ACCEPTED_PATTERN.exec(message)?.groups?.accepted + return { rejected, accepted } +} + +// A client picks the thinking type from the model ID that it knows. A router +// can map that ID to a Copilot model with other rules. This keeps, per +// upstream model, the type to send instead of a requested type. +export interface ThinkingRequest { + model: string + requested: string +} + +class ThinkingTypeReplacements { + readonly #byModel = new Map>() + + typeToSend({ model, requested }: ThinkingRequest): string { + return this.#byModel.get(model)?.get(requested) ?? requested + } + + // Call after HTTP 400. Forgets a replacement that Copilot now rejects and + // returns the type that the error names, if there is one. + retryType( + { model, requested }: ThinkingRequest, + sent: string, + errorBody: string, + ): string | undefined { + const rejection = parseThinkingRejection(errorBody) + if (rejection?.rejected !== sent) return undefined + this.#byModel.get(model)?.delete(requested) + return rejection.accepted === sent ? undefined : rejection.accepted + } + + // Call after a retry with `accepted` succeeds. + remember({ model, requested }: ThinkingRequest, accepted: string): void { + if (accepted === requested) return + const replacements = this.#byModel.get(model) ?? new Map() + replacements.set(requested, accepted) + this.#byModel.set(model, replacements) + } + + // The cache lives for the process. Tests reset it between cases. + clear(): void { + this.#byModel.clear() + } +} + +export const thinkingTypeReplacements = new ThinkingTypeReplacements() diff --git a/src/start.ts b/src/start.ts index 14abbbdff..cb1b60f47 100644 --- a/src/start.ts +++ b/src/start.ts @@ -25,6 +25,7 @@ interface RunServerOptions { claudeCode: boolean showToken: boolean proxyEnv: boolean + nativeMessages: boolean } export async function runServer(options: RunServerOptions): Promise { @@ -46,6 +47,7 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitSeconds = options.rateLimit state.rateLimitWait = options.rateLimitWait state.showToken = options.showToken + state.nativeMessages = options.nativeMessages await ensurePaths() await cacheVSCodeVersion() @@ -184,6 +186,12 @@ export const start = defineCommand({ default: false, description: "Initialize proxy from environment variables", }, + "native-messages": { + type: "boolean", + default: true, + description: + "Translate Claude models via /chat/completions instead of native /v1/messages", + }, }, run({ args }) { const rateLimitRaw = args["rate-limit"] @@ -202,6 +210,7 @@ export const start = defineCommand({ claudeCode: args["claude-code"], showToken: args["show-token"], proxyEnv: args["proxy-env"], + nativeMessages: args["native-messages"], }) }, }) diff --git a/tests/fixtures/thinking-errors.ts b/tests/fixtures/thinking-errors.ts new file mode 100644 index 000000000..15668a123 --- /dev/null +++ b/tests/fixtures/thinking-errors.ts @@ -0,0 +1,17 @@ +// Error messages returned by Copilot /v1/messages on 2026-10-27. +// Opus 5.5, Sonnet 5.5 and Haiku 5.5 return the same text for "enabled". +export const ENABLED_REJECTED = + '"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior.' +export const OPUS_DISABLED_REJECTED = + '"thinking.type.disabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior.' +export const SONNET_DISABLED_REJECTED = + 'To turn thinking off on this model, send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}. The model does not think before responding.' +export const BETWEEN_TOOLS_REJECTED = + '"thinking.type.between_tools" is not supported for this model.' + +export function errorBody(message: string): string { + return JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message }, + }) +} diff --git a/tests/native-messages.test.ts b/tests/native-messages.test.ts new file mode 100644 index 000000000..3b6fd177f --- /dev/null +++ b/tests/native-messages.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" + +import { HTTPError } from "~/lib/error" +import { state } from "~/lib/state" +import { shouldUseNativeMessages } from "~/routes/messages/native-handler" +import { server } from "~/server" +import { + createNativeMessages, + type NativeMessagesPayload, +} from "~/services/copilot/create-native-messages" +import { thinkingTypeReplacements } from "~/services/copilot/thinking-type" + +import { + ENABLED_REJECTED, + errorBody, + OPUS_DISABLED_REJECTED, +} from "./fixtures/thinking-errors" + +interface FetchInit { + headers: Record + body: string +} + +const realFetch = globalThis.fetch +let calls: Array<{ url: string; init: FetchInit }> = [] + +function mockFetch(...responses: Array): void { + calls = [] + const fetchMock = mock((url: string, init: FetchInit) => { + calls.push({ url, init }) + return Promise.resolve(responses[calls.length - 1]) + }) + globalThis.fetch = fetchMock as unknown as typeof fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function rejection(message: string): Response { + return new Response(errorBody(message), { + status: 400, + headers: { "content-type": "application/json" }, + }) +} + +function sentBody(index: number): Record { + return JSON.parse(calls[index].init.body) as Record +} + +function payload(thinkingType: string): NativeMessagesPayload { + return { + model: "claude-opus-5-5", + messages: [{ role: "user", content: "hi" }], + thinking: { type: thinkingType }, + output_config: { effort: "high" }, + } +} + +function requestMessages(body: unknown): Promise { + return Promise.resolve( + server.request("/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) +} + +beforeEach(() => { + state.copilotToken = "test-token" + state.vsCodeVersion = "1.0.0" + state.accountType = "individual" + state.nativeMessages = true + thinkingTypeReplacements.clear() +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +describe("createNativeMessages", () => { + test("forwards the payload and the beta header unchanged", async () => { + mockFetch(jsonResponse({ type: "message" })) + const body = { ...payload("adaptive"), max_tokens: 16 } + await createNativeMessages(body, { + "anthropic-beta": "context-1m-2025-08-07", + }) + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe("https://api.githubcopilot.com/v1/messages") + expect(sentBody(0)).toEqual(body) + expect(calls[0].init.headers["anthropic-beta"]).toBe( + "context-1m-2025-08-07", + ) + expect(calls[0].init.headers["X-Initiator"]).toBe("user") + }) + + test("retries once with the accepted type and caches it after success", async () => { + mockFetch( + rejection(OPUS_DISABLED_REJECTED), + jsonResponse({ type: "message" }), + jsonResponse({ type: "message" }), + ) + const original = payload("disabled") + await createNativeMessages(original, {}) + expect(calls).toHaveLength(2) + expect(sentBody(1).thinking).toEqual({ type: "adaptive" }) + expect(sentBody(1).output_config).toEqual({ effort: "high" }) + expect(original.thinking).toEqual({ type: "disabled" }) + + await createNativeMessages(payload("disabled"), {}) + expect(calls).toHaveLength(3) + expect(sentBody(2).thinking).toEqual({ type: "adaptive" }) + }) + + test("does not cache the accepted type when the retry fails", async () => { + mockFetch( + rejection(OPUS_DISABLED_REJECTED), + rejection("Another problem."), + rejection(OPUS_DISABLED_REJECTED), + jsonResponse({ type: "message" }), + ) + const failed = await createNativeMessages(payload("disabled"), {}).catch( + (error: unknown) => error, + ) + expect(failed).toBeInstanceOf(HTTPError) + + await createNativeMessages(payload("disabled"), {}) + expect(calls).toHaveLength(4) + expect(sentBody(2).thinking).toEqual({ type: "disabled" }) + }) + + test("keeps thinking.display and drops budget_tokens in the retry", async () => { + mockFetch(rejection(ENABLED_REJECTED), jsonResponse({ type: "message" })) + const body = { + ...payload("enabled"), + thinking: { type: "enabled", budget_tokens: 1024, display: "summarized" }, + } + await createNativeMessages(body, {}) + expect(sentBody(1).thinking).toEqual({ + type: "adaptive", + display: "summarized", + }) + }) + + test("replaces a cached type that Copilot rejects later", async () => { + mockFetch( + rejection(OPUS_DISABLED_REJECTED), + jsonResponse({ type: "message" }), + rejection( + '"thinking.type.adaptive" is not supported for this model. Use "thinking.type.between_tools" instead.', + ), + jsonResponse({ type: "message" }), + jsonResponse({ type: "message" }), + ) + await createNativeMessages(payload("disabled"), {}) + await createNativeMessages(payload("disabled"), {}) + await createNativeMessages(payload("disabled"), {}) + expect(calls).toHaveLength(5) + expect(sentBody(2).thinking).toEqual({ type: "adaptive" }) + expect(sentBody(3).thinking).toEqual({ type: "between_tools" }) + expect(sentBody(4).thinking).toEqual({ type: "between_tools" }) + }) + + test("does not retry other errors", async () => { + mockFetch(jsonResponse({ type: "error" }, 429)) + const failed = await createNativeMessages(payload("disabled"), {}).catch( + (error: unknown) => error, + ) + expect(failed).toBeInstanceOf(HTTPError) + expect(calls).toHaveLength(1) + }) + + test("marks agent calls and images inside tool results", async () => { + mockFetch(jsonResponse({ type: "message" })) + await createNativeMessages( + { + model: "claude-opus-5-5", + messages: [ + { role: "user", content: "Read a.png." }, + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: [{ type: "image", source: {} }], + }, + ], + }, + ], + }, + {}, + ) + expect(calls[0].init.headers["X-Initiator"]).toBe("agent") + expect(calls[0].init.headers["copilot-vision-request"]).toBe("true") + }) +}) + +describe("shouldUseNativeMessages", () => { + const userTurn = [{ role: "user", content: "hi" }] + + test("selects Claude models", () => { + expect( + shouldUseNativeMessages({ model: "claude-opus-5", messages: userTurn }), + ).toBe(true) + expect( + shouldUseNativeMessages({ model: "gpt-4.1", messages: userTurn }), + ).toBe(false) + }) + + test("keeps the translation path for a trailing assistant prefill", () => { + expect( + shouldUseNativeMessages({ + model: "claude-opus-5", + messages: [...userTurn, { role: "assistant", content: "{" }], + }), + ).toBe(false) + }) + + test("follows the native-messages option", () => { + state.nativeMessages = false + expect( + shouldUseNativeMessages({ model: "claude-opus-5", messages: userTurn }), + ).toBe(false) + }) +}) + +describe("POST /v1/messages", () => { + test("streams the native response to the client unchanged", async () => { + const events = 'event: message_start\ndata: {"type":"message_start"}\n\n' + mockFetch( + new Response(events, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }, + }), + ) + const body = { ...payload("adaptive"), max_tokens: 16, stream: true } + const response = await requestMessages(body) + expect(calls[0].url).toEndWith("/v1/messages") + expect(sentBody(0)).toEqual(body) + expect(response.headers.get("content-type")).toBe("text/event-stream") + expect(response.headers.get("cache-control")).toBe("no-cache") + expect(await response.text()).toBe(events) + }) + + test("returns upstream errors with status, body and retry-after", async () => { + mockFetch( + new Response('{"type":"error"}', { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "7", + "x-should-retry": "true", + }, + }), + ) + const response = await requestMessages({ + ...payload("adaptive"), + max_tokens: 16, + }) + expect(response.status).toBe(429) + expect(response.headers.get("retry-after")).toBe("7") + expect(response.headers.get("x-should-retry")).toBe("true") + expect(await response.text()).toBe('{"type":"error"}') + }) + + test("sends other models through /chat/completions", async () => { + mockFetch( + jsonResponse({ + id: "1", + object: "chat.completion", + created: 0, + model: "gpt-4.1", + choices: [ + { + index: 0, + message: { role: "assistant", content: "hi" }, + logprobs: null, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ) + const response = await requestMessages({ + model: "gpt-4.1", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }) + expect(calls[0].url).toEndWith("/chat/completions") + expect(response.status).toBe(200) + }) +}) diff --git a/tests/thinking-type.test.ts b/tests/thinking-type.test.ts new file mode 100644 index 000000000..5e95ae310 --- /dev/null +++ b/tests/thinking-type.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, test } from "bun:test" + +import { + parseThinkingRejection, + thinkingTypeReplacements, +} from "~/services/copilot/thinking-type" + +import { + BETWEEN_TOOLS_REJECTED, + ENABLED_REJECTED, + errorBody, + OPUS_DISABLED_REJECTED, + SONNET_DISABLED_REJECTED, +} from "./fixtures/thinking-errors" + +describe("parseThinkingRejection", () => { + test("reads the rejected type and the type that follows Use", () => { + expect(parseThinkingRejection(ENABLED_REJECTED)).toEqual({ + rejected: "enabled", + accepted: "adaptive", + }) + expect(parseThinkingRejection(OPUS_DISABLED_REJECTED)).toEqual({ + rejected: "disabled", + accepted: "adaptive", + }) + }) + + test("reads the replacement type", () => { + expect(parseThinkingRejection(SONNET_DISABLED_REJECTED)).toEqual({ + rejected: "disabled", + accepted: "between_tools", + }) + }) + + test("reads the message from an Anthropic error body", () => { + expect(parseThinkingRejection(errorBody(OPUS_DISABLED_REJECTED))).toEqual({ + rejected: "disabled", + accepted: "adaptive", + }) + }) + + test("returns no accepted type when the error names none", () => { + expect(parseThinkingRejection(BETWEEN_TOOLS_REJECTED)).toEqual({ + rejected: "between_tools", + accepted: undefined, + }) + }) + + test("returns nothing for other errors", () => { + expect( + parseThinkingRejection("The requested model is not supported."), + ).toBeUndefined() + }) +}) + +describe("thinkingTypeReplacements", () => { + const opusDisabled = { model: "claude-opus-5-5", requested: "disabled" } + + beforeEach(() => { + thinkingTypeReplacements.clear() + }) + + test("sends the requested type until a replacement is remembered", () => { + expect(thinkingTypeReplacements.typeToSend(opusDisabled)).toBe("disabled") + thinkingTypeReplacements.remember(opusDisabled, "adaptive") + expect(thinkingTypeReplacements.typeToSend(opusDisabled)).toBe("adaptive") + expect( + thinkingTypeReplacements.typeToSend({ + model: "claude-sonnet-5-5", + requested: "disabled", + }), + ).toBe("disabled") + }) + + test("returns the retry type only when the error rejects the sent type", () => { + expect( + thinkingTypeReplacements.retryType( + { model: "claude-opus-5-5", requested: "disabled" }, + "disabled", + OPUS_DISABLED_REJECTED, + ), + ).toBe("adaptive") + expect( + thinkingTypeReplacements.retryType( + { model: "claude-opus-5-5", requested: "enabled" }, + "enabled", + OPUS_DISABLED_REJECTED, + ), + ).toBeUndefined() + expect( + thinkingTypeReplacements.retryType( + { model: "claude-opus-5-5", requested: "between_tools" }, + "between_tools", + BETWEEN_TOOLS_REJECTED, + ), + ).toBeUndefined() + }) + + test("forgets a replacement that Copilot rejects", () => { + thinkingTypeReplacements.remember(opusDisabled, "adaptive") + thinkingTypeReplacements.retryType( + { model: "claude-opus-5-5", requested: "disabled" }, + "adaptive", + '"thinking.type.adaptive" is not supported for this model.', + ) + expect(thinkingTypeReplacements.typeToSend(opusDisabled)).toBe("disabled") + }) +})