diff --git a/README.md b/README.md index b55439e..679d77b 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,9 @@ Your proxy must advertise the exact ID for a model to be available. | Google models | `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` | | xAI models | `grok-imagine-image`, `grok-imagine-image-quality`, `grok-imagine-image-2.0`, `grok-imagine-video`, `grok-imagine-video-1.5`, `grok-imagine-video-1.5-preview` | -The listed Gemini image models also support advertised `vertex/` and `antigravity/` routes. Those prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, media aliases, unknown media IDs, Google Veo, and editing endpoints are unsupported. +Supported media IDs can also use advertised `api/` and `oauth/` credential routes. Gemini image models also support `vertex/` and `antigravity/` backend routes. Those backend prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, nested prefixes, media aliases, unknown media IDs, Google Veo, and editing endpoints are unsupported. -The extension disables these retired Imagen IDs, including their `vertex/` and `antigravity/` forms: +The extension disables these retired Imagen IDs, including their supported prefixed forms: - `imagen-3.0-generate-002` - `imagen-3.0-fast-generate-001` @@ -199,6 +199,8 @@ Invalid management credentials, rejected account access, invalid usage data, or Management requests stay on the configured proxy and reject redirects. Account access tokens stay in CLIProxyAPI. The extension reads account metadata and proxies usage GETs; it does not reset quotas, change credentials, or clear proxy cooldowns. Management keys grant broader access than chat keys, so keep them private. Live lookup supports up to 100 active Claude subscription accounts. +Explicit `api/` routes skip live Claude subscription lookup, even with a management key. `oauth/` routes keep the lookup for matching OAuth accounts. Retries keep the selected route's full model ID. + Other chat routes retain the response-based fallback: `Retry-After` on HTTP 429 or a recognized quota HTTP 403, or `reset_seconds` in a `model_cooldown` error. Google's native adapter exposes only the structured error body. Those waits share the same seven-day deadline but cannot inspect live weekly usage. Image and video generation still do not retry. ### Compaction @@ -225,9 +227,9 @@ To describe a proxy alias, add a canonical metadata reference to `~/.pi/agent/pi Replace `` with an ID from Pi's Anthropic catalog. References can also use `openai/`, `openai-codex/`, or `google/`. Aliases select metadata; requests retain the original proxy ID. -A `vertex/` or `antigravity/` route can reuse its unprefixed ID's alias if that reference resolves to one metadata entry. An alias for the full prefixed ID takes precedence. Custom prefixes need an explicit chat alias. Arbitrary aliases may lack ID-specific adapter behavior; canonical Gemini IDs and the two recognized prefixes retain native thinking and tool-turn handling. +The recognized prefixes are `vertex/`, `antigravity/`, `api/`, and `oauth/`. Each can reuse its unprefixed ID's alias if that reference resolves to one metadata entry. An alias for the full prefixed ID takes precedence. Other prefixes need an explicit chat alias. Arbitrary aliases may lack ID-specific adapter behavior; canonical Gemini IDs and the recognized prefixes retain native thinking and tool-turn handling. -For Antigravity IDs `claude-opus-5-5-high` and `claude-sonnet-5-5-high`, the extension falls back to `anthropic/claude-opus-5-5` and `anthropic/claude-sonnet-5-5` metadata. This applies to bare IDs and recognized `antigravity/` or `vertex/` routes. Explicit aliases take precedence, followed by exact native metadata. The fallback requires the corresponding canonical entry in Pi and names the model like a configured alias, for example `claude-opus-5-5-high (Claude Opus 5.5)`; requests keep the advertised `-high` ID. +For Antigravity IDs `claude-opus-5-5-high` and `claude-sonnet-5-5-high`, the extension falls back to `anthropic/claude-opus-5-5` and `anthropic/claude-sonnet-5-5` metadata. This applies to bare IDs and the recognized route prefixes. Explicit aliases take precedence, followed by exact native metadata. The fallback requires the corresponding canonical entry in Pi and names the model like a configured alias, for example `claude-opus-5-5-high (Claude Opus 5.5)`; requests keep the advertised `-high` ID. Use Pi's `models.json` `modelOverrides` for limits, prices, or compatibility changes. Use its `models` array for IDs with no built-in metadata. Updating Pi supplies newer metadata. Catalog prices are estimates, not the proxy's bill; check upstream limits before increasing them. @@ -244,6 +246,27 @@ These examples select media defaults. Chat routes belong in Pi's built-in `/mode Set the CLIProxyAPI auth record's top-level `prefix` to `vertex` or `antigravity` to advertise those routes. With `routing.force-model-prefix: false` in CLIProxyAPI 8, the proxy retains bare IDs too. Legacy configurations use the top-level `force-model-prefix` setting. This extension does not change proxy configuration or restart it. +## OAuth and API-key routes + +CLIProxyAPI can advertise separate model IDs for OAuth and API-key credentials. The extension labels `oauth/` as **OAuth** and `api/` as **API key**. It matches the unprefixed model's metadata and sends the full advertised ID on requests. It does not retry an explicit route as a bare ID or another credential route. + +Configure the prefixes in CLIProxyAPI's v8 layout: + +- For an API-key group, set `api-keys.[].prefix` to `api`. A key-level `prefix` can override the group value. +- For an OAuth credential, set the auth record's top-level `prefix` to `oauth`. + +If a legacy file stores client keys in the root `api-keys` list, migrate them to `access.api-keys` before adding provider groups. A successful v8 management write performs this migration. + +CLIProxyAPI also supports per-group and per-key model aliases, exclusions, priorities, and proxy overrides. See its [configuration fields](https://github.com/router-for-me/cliproxyapidocs/blob/main/docs/en/configuration/options.md). + +The [v8 management API](https://github.com/router-for-me/cliproxyapidocs/blob/main/docs/en/management/apiv8.md) manages API-key groups through `/v8/management/config/api-keys/`. These lists must be replaced as a whole; numeric paths cannot select an entry. OAuth fields use `PATCH /v8/management/credentials/fields`. The extension does not call these endpoints or need a management key for model discovery. + +After setting the prefixes, run `/cliproxyapi-refresh`. Choose `cliproxyapi/oauth/` or `cliproxyapi/api/` in Pi's `/model` picker. Media routes appear in `/cli:model`. + +The extension trusts your prefix configuration; it does not inspect credential types. Keep each prefix limited to its matching credential type. Unprefixed IDs remain **Automatic (proxy routing)** and may use either pool. Set `routing.force-model-prefix: true` in CLIProxyAPI to exclude prefixed credentials from unprefixed requests. + +Chat and media selections remain independent. The automatic OpenAI image default is still unprefixed; select a prefixed media model to choose its credential route. + ## Thinking and child sessions Use a reference such as `cliproxyapi/:high` in Pi or pi-subagents. Pi separates the thinking suffix from the request ID. Supported levels come from model metadata; `xhigh` and `max` require explicit support. Pi clamps unsupported levels, and models that require thinking cannot use `off`. Keep thinking suffixes out of alias references. diff --git a/src/catalog.ts b/src/catalog.ts index 6b1f726..2470e8e 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -36,15 +36,20 @@ export function parseCatalog(value: unknown) { // Resolve metadata and capabilities without changing registry or request IDs. export function modelRoute(id: string) { - const match = /^(vertex|antigravity)\/(.+)$/.exec(id); + const match = /^(vertex|antigravity|oauth|api)\/(.+)$/.exec(id); + const prefix = match?.[1]; return { metadataId: match?.[2] ?? id, - backend: match ? (match[1] === "vertex" ? "Vertex" : "Antigravity") : undefined, + prefix, + backend: prefix === "vertex" ? "Vertex" : prefix === "antigravity" ? "Antigravity" : undefined, + authType: prefix === "api" ? "api-key" : prefix === "oauth" ? "oauth" : undefined, }; } export function backendLabel(id: string) { - return modelRoute(id).backend ?? (id.includes("/") ? "Unknown backend" : "Automatic (proxy routing)"); + const { backend, authType } = modelRoute(id); + if (authType) return authType === "api-key" ? "API key" : "OAuth"; + return backend ?? (id.includes("/") ? "Unknown backend" : "Automatic (proxy routing)"); } export function routedName(id: string, name = id) { @@ -188,11 +193,11 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model skipped.add(entry.id); continue; } - const { metadataId, backend } = modelRoute(entry.id); - const routed = !entry.id.includes("/") || backend; + const { metadataId, prefix } = modelRoute(entry.id); + const routed = !entry.id.includes("/") || prefix; const alias = Object.hasOwn(config.aliases, entry.id) ? config.aliases[entry.id] - : backend && Object.hasOwn(config.aliases, metadataId) + : prefix && Object.hasOwn(config.aliases, metadataId) ? config.aliases[metadataId] : routed && !byId.has(metadataId) ? metadataFallbacks.get(metadataId) diff --git a/src/claude-quota.ts b/src/claude-quota.ts index 646cc5e..59dd30a 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -1,4 +1,5 @@ import { setTimeout as sleep } from "node:timers/promises"; +import { modelRoute } from "./catalog.ts"; import { type Config, isRecord } from "./config.ts"; export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void; @@ -150,7 +151,9 @@ export async function fetchClaudeQuota( // ponytail: probe at most 100 accounts; batch larger pools before raising this limit. if (accounts.length > 100) throw new Error("Live quota lookup supports at most 100 active Claude accounts."); - const reference = config.aliases[modelId] ?? modelId; + const { metadataId, prefix } = modelRoute(modelId); + const reference = + config.aliases[modelId] ?? (prefix ? config.aliases[metadataId] : undefined) ?? metadataId; const results = await Promise.allSettled( accounts.map(async (account: unknown) => { if ( diff --git a/src/media-defaults.ts b/src/media-defaults.ts index f910ad6..75f5dac 100644 --- a/src/media-defaults.ts +++ b/src/media-defaults.ts @@ -34,12 +34,12 @@ export function readMediaDefaults(config: Config, ctx: DefaultsContext) { export function automaticImageDefault(config: Config, ctx: DefaultsContext) { const model = ctx.model; if (!model || mediaPurpose(model.id)) return undefined; - const { metadataId, backend } = modelRoute(model.id); + const { metadataId, prefix } = modelRoute(model.id); const alias = model.provider === PROVIDER_ID ? Object.hasOwn(config.aliases, model.id) ? config.aliases[model.id] - : backend && Object.hasOwn(config.aliases, metadataId) + : prefix && Object.hasOwn(config.aliases, metadataId) ? config.aliases[metadataId] : undefined : undefined; diff --git a/src/provider.ts b/src/provider.ts index 514339c..d6b8645 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -188,11 +188,12 @@ function route( (selected, context, options) => { // A resumed selection must not send the current key to an old endpoint. const model = { ...selected, baseUrl: endpoint(config.baseUrl, selected.api) }; - const { metadataId, backend } = modelRoute(model.id); - const google = backend && model.api === "google-generative-ai"; + const { metadataId, prefix, backend, authType } = modelRoute(model.id); + const google = prefix && model.api === "google-generative-ai"; const managementKey = options?.env?.CLIPROXYAPI_MANAGEMENT_KEY ?? process.env.CLIPROXYAPI_MANAGEMENT_KEY ?? ""; - const liveClaude = model.api === "anthropic-messages" && !backend && managementKey !== ""; + const liveClaude = + model.api === "anthropic-messages" && !backend && authType !== "api-key" && managementKey !== ""; const adapterModel = google ? { ...model, id: metadataId } : model; // Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route. const adapterContext = google @@ -350,7 +351,7 @@ export function createCliproxyProvider( ) { const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]); const scope = createHash("sha256") - .update(JSON.stringify([4, { baseUrl: config.baseUrl, aliases: config.aliases }])) + .update(JSON.stringify([5, { baseUrl: config.baseUrl, aliases: config.aliases }])) .digest("hex"); const provider = createProvider({ id: PROVIDER_ID, diff --git a/test/auth-routes.test.ts b/test/auth-routes.test.ts new file mode 100644 index 0000000..cd4bbb9 --- /dev/null +++ b/test/auth-routes.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mapCatalog, mapMediaCatalog, mediaCapability, mediaControls } from "../src/catalog.ts"; +import { PROVIDER_ID, parseConfig } from "../src/config.ts"; +import { automaticImageDefault, OPENAI_IMAGE_DEFAULT } from "../src/media-defaults.ts"; +import { pickerCatalog, searchPickerItems } from "../src/picker.ts"; +import { builtinCatalog } from "../src/provider.ts"; + +const mediaIds = ["gpt-image-1.5", "gemini-3-pro-image", "grok-imagine-image", "grok-imagine-video"]; + +test("auth-prefixed media retains its execution route, controls and searchable labels", () => { + for (const prefix of ["api", "oauth"]) { + const ids = mediaIds.map((id) => `${prefix}/${id}`); + const catalog = { data: ids.map((id) => ({ id })) }; + const models = mapMediaCatalog(catalog); + assert.deepEqual( + models.map((model) => model.id), + ids, + ); + for (const [index, id] of ids.entries()) { + assert.equal(mediaCapability(id)?.route, mediaCapability(mediaIds[index])?.route); + assert.deepEqual(mediaControls(id), mediaControls(mediaIds[index])); + assert.match(models[index].name, prefix === "api" ? / · API key$/ : / · OAuth$/); + } + const rows = pickerCatalog(catalog); + assert.equal(rows.length, ids.length); + assert.ok(rows.every((row) => row.supported)); + assert.deepEqual( + searchPickerItems( + rows.map((row) => ({ + value: row.id, + label: row.name, + description: row.backend, + supported: row.supported, + })), + prefix === "api" ? "API key" : "OAuth", + ).map((row) => row.value), + ids, + ); + } +}); + +test("auth prefixes preserve hidden, retired and unknown media exclusions", () => { + for (const prefix of ["api", "oauth"]) { + const retired = `${prefix}/imagen-4.0-generate-001`; + const unknown = `${prefix}/future-image`; + const hidden = `${prefix}/gpt-image-1.5`; + const catalog = { data: [{ id: retired }, { id: unknown }, { id: hidden, visibility: "hide" }] }; + assert.deepEqual(mapMediaCatalog(catalog), []); + const rows = pickerCatalog(catalog); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, retired); + assert.equal(rows[0].supported, false); + assert.match(rows[0].disabledReason ?? "", /Retired/); + } + for (const id of ["custom/gpt-image-1.5", "API/gpt-image-1.5", "api/custom/gpt-image-1.5"]) + assert.equal(mediaCapability(id), undefined); + assert.match(mediaCapability("vertex/gpt-image-1.5")?.disabledReason ?? "", /Unsupported/); +}); + +test("auth-prefixed media cannot become chat through metadata aliases", () => { + const known = builtinCatalog(); + const reference = known.find((model) => model.provider === "openai"); + assert.ok(reference); + const ids = ["api/gpt-image-1.5", "oauth/grok-imagine-video"]; + const config = parseConfig({ + aliases: Object.fromEntries(ids.map((id) => [id, `openai/${reference.id}`])), + }); + assert.deepEqual(mapCatalog({ data: ids.map((id) => ({ id })) }, config, known).models, []); +}); + +test("automatic media selection remains separate from the chat credential route", () => { + const reference = builtinCatalog().find((model) => model.provider === "openai"); + assert.ok(reference); + for (const prefix of ["api", "oauth"]) { + const model: typeof reference = { ...reference, provider: PROVIDER_ID, id: `${prefix}/gpt-fixture` }; + assert.equal(automaticImageDefault(parseConfig({}), { model }), OPENAI_IMAGE_DEFAULT); + const config = parseConfig({ aliases: { "team-chat": "openai/gpt-fixture" } }); + const aliasModel: typeof reference = { ...model, id: `${prefix}/team-chat` }; + assert.equal(automaticImageDefault(config, { model: aliasModel }), OPENAI_IMAGE_DEFAULT); + config.aliases[aliasModel.id] = "anthropic/claude-fixture"; + assert.equal(automaticImageDefault(config, { model: aliasModel }), undefined); + } +}); diff --git a/test/provider.test.ts b/test/provider.test.ts index 83ff164..e2ce40c 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -200,13 +200,51 @@ test("aliases keep wire IDs, hidden entries disappear, and unknowns are not gues ); }); +test("auth prefixes keep separate entries and reuse metadata without guessing custom prefixes", () => { + const config = parseConfig({ + aliases: { + "team-chat": "anthropic/claude-fixture", + "api/team-chat": "openai/gpt-fixture", + }, + }); + const ids = known.flatMap(({ id }) => [id, `oauth/${id}`, `api/${id}`]); + const data = [...ids, "oauth/team-chat", "api/team-chat", "custom/claude-fixture", "API/claude-fixture"]; + const mapped = mapCatalog({ data: data.map((id) => ({ id })) }, config, known); + assert.deepEqual( + mapped.models.map((model) => model.id), + data.slice(0, -2), + ); + assert.deepEqual(mapped.skipped, data.slice(-2)); + for (const model of mapped.models) { + assert.match( + model.name, + model.id.startsWith("api/") + ? / · API key$/ + : model.id.startsWith("oauth/") + ? / · OAuth$/ + : / · Automatic \(proxy routing\)$/, + ); + } + assert.equal(mapped.models.find((model) => model.id === "oauth/team-chat")?.api, "anthropic-messages"); + assert.equal(mapped.models.find((model) => model.id === "api/team-chat")?.api, "openai-responses"); + const ambiguous = [...known, { ...known[1], id: known[0].id }]; + assert.deepEqual(mapCatalog({ data: [{ id: "api/claude-fixture" }] }, config, ambiguous).models, []); + assert.equal( + mapCatalog({ data: [{ id: "api/claude-fixture", owned_by: "claude" }] }, config, ambiguous).models[0].api, + "anthropic-messages", + ); +}); + test("Antigravity high IDs use exact Claude metadata fallbacks without changing advertised IDs", () => { const sources = builtinCatalog(); for (const canonical of ["claude-opus-5-5", "claude-sonnet-5-5"]) { const reference = sources.find((model) => model.provider === "anthropic" && model.id === canonical); assert.ok(reference); const bare = `${canonical}-high`; - for (const id of [bare, `antigravity/${bare}`, `vertex/${bare}`]) { + for (const id of [ + bare, + ...["antigravity", "vertex", "api", "oauth"].map((prefix) => `${prefix}/${bare}`), + ]) { const catalog = { data: [{ id }] }; const mapped = mapCatalog(catalog, parseConfig({}), sources); assert.deepEqual(mapped.skipped, []); @@ -588,7 +626,7 @@ function respond(res: ServerResponse, api: string, model?: string) { test("native adapters stream each family and qualified route through the right endpoint with the proxy key", async (t) => { for (const source of known.flatMap((model) => [ model, - ...["vertex", "antigravity"].map((prefix) => ({ ...model, id: `${prefix}/${model.id}` })), + ...["vertex", "antigravity", "api", "oauth"].map((prefix) => ({ ...model, id: `${prefix}/${model.id}` })), ])) { await t.test(source.id, async (t) => { const actualApi = source.api === "openai-codex-responses" ? "openai-responses" : source.api; @@ -761,7 +799,12 @@ test("quota timing requires a temporary limit and a bounded future reset", () => test("native chat adapters wait for quota reset before retrying the same request", { timeout: 20000, }, async (t) => { - for (const source of [known[1], known[4], { ...known[3], id: `antigravity/${known[3].id}` }]) { + for (const source of [ + known[1], + known[4], + { ...known[0], id: `api/${known[0].id}` }, + { ...known[3], id: `antigravity/${known[3].id}` }, + ]) { await t.test(source.api, async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.parse("2030-01-01T00:00:00Z") }); const waiting = Promise.withResolvers(); @@ -792,7 +835,13 @@ test("native chat adapters wait for quota reset before retrying the same request const stream = provider.streamSimple( model, normalizeContext({ messages: [{ role: "user", content: "Return ok.", timestamp: 0 }] }), - { apiKey: key, maxRetries: 0, timeoutMs: 500, maxTokens: 32 }, + { + apiKey: key, + maxRetries: 0, + timeoutMs: 500, + maxTokens: 32, + env: { CLIPROXYAPI_MANAGEMENT_KEY: "fixture-management-key" }, + }, ); const events: string[] = []; const completed = (async () => { @@ -915,6 +964,7 @@ function mockClaudeManagement(t: TestContext, baseUrl: string, usage: (account: const seen = { calls: 0, status: 200, + modelIds: [known[0].id], accounts: [ { provider: "claude", @@ -933,7 +983,8 @@ function mockClaudeManagement(t: TestContext, baseUrl: string, usage: (account: assert.equal(init?.redirect, "error"); assert.ok(init?.signal); if (url.pathname.endsWith("/auth-files")) return Response.json({ files: seen.accounts }); - if (url.pathname.endsWith("/auth-files/models")) return Response.json({ models: [{ id: known[0].id }] }); + if (url.pathname.endsWith("/auth-files/models")) + return Response.json({ models: seen.modelIds.map((id) => ({ id })) }); assert.ok(url.pathname.endsWith("/api-call")); assert.equal(init?.method, "POST"); const body = JSON.parse(String(init?.body)); @@ -1328,6 +1379,103 @@ test("one malformed account does not hide usable quota in the rest of the pool", assert.equal(seen.calls, 2); }); +test("API-key routes return billing and rate errors without OAuth lookup or route fallback", async (t) => { + for (const status of [400, 429]) { + await t.test(String(status), async (t) => { + const id = `api/${known[0].id}`; + const requests: string[] = []; + const baseUrl = await server(t, (req, res) => { + requests.push(new URL(req.url ?? "", "http://localhost").pathname); + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + assert.equal(new URL(req.url ?? "", "http://localhost").pathname, "/v1/messages"); + assert.equal(JSON.parse(body).model, id); + res.writeHead(status, { "content-type": "application/json" }).end( + JSON.stringify({ + error: { + type: status === 429 ? "rate_limit_error" : "invalid_request_error", + message: "Fixture API limit", + }, + }), + ); + }); + }); + const config = parseConfig({ baseUrl }); + const provider = createCliproxyProvider(config, known, () => assert.fail("Must not wait on OAuth")); + const model = mapCatalog({ data: [{ id }] }, config, known).models[0]; + assert.ok(model); + const result = await provider + .streamSimple( + model, + normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), + { + apiKey: key, + maxRetries: 0, + signal: AbortSignal.timeout(5000), + env: { CLIPROXYAPI_MANAGEMENT_KEY: "fixture-management-key" }, + }, + ) + .result(); + assert.equal(result.stopReason, "error"); + assert.match(result.errorMessage ?? "", /Fixture API limit/); + assert.deepEqual(requests, ["/v1/messages"]); + }); + } +}); + +test("OAuth routes retain live quota handling for the prefixed model", async (t) => { + const id = `oauth/${known[0].id}`; + let requests = 0; + const baseUrl = await server(t, (req, res) => { + req.resume(); + requests++; + claudeCooldown(res); + }); + const seen = mockClaudeManagement(t, baseUrl, () => claudeUsage(Date.now() + 900000, 0, 100)); + seen.modelIds = [id]; + const config = parseConfig({ baseUrl }); + const provider = createCliproxyProvider(config, known); + const model = mapCatalog({ data: [{ id }] }, config, known).models[0]; + assert.ok(model); + const result = await provider + .streamSimple( + model, + normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), + { + apiKey: key, + maxRetries: 0, + signal: AbortSignal.timeout(5000), + env: { CLIPROXYAPI_MANAGEMENT_KEY: "fixture-management-key" }, + }, + ) + .result(); + assert.match(result.errorMessage ?? "", /Seven-day quota is exhausted/); + assert.equal(seen.calls, 1); + assert.equal(requests, 1); +}); + +test("OAuth alias quota lookup uses canonical metadata and prefers the full alias", async (t) => { + const id = "oauth/team-chat"; + const baseUrl = "https://proxy.example"; + const seen = mockClaudeManagement(t, baseUrl, () => ({ + ...claudeUsage(Date.now() + 900000, 0), + seven_day_opus: { utilization: 100, resets_at: new Date(Date.now() + 86400000).toISOString() }, + })); + seen.modelIds = [id]; + const config = parseConfig({ + baseUrl, + aliases: { "team-chat": "anthropic/claude-opus-fixture" }, + quota: { waitForWeeklyReset: true }, + }); + assert.equal((await fetchClaudeQuota(config, "fixture-management-key", id)).state, "blocked"); + config.aliases[id] = "anthropic/claude-sonnet-fixture"; + assert.equal((await fetchClaudeQuota(config, "fixture-management-key", id)).state, "ready"); +}); + test("repeated header cooldowns cannot extend the original weekly waiting deadline", { timeout: 10000, }, async (t) => { @@ -1759,7 +1907,10 @@ test("catalog preserves separate backend IDs, canonical aliases and exact alias }); test("qualified chat IDs restore in the native registry and resolve exact CLI thinking references", async (t) => { - const ids = [known[0].id, `vertex/${known[0].id}`, `antigravity/${known[0].id}`]; + const ids = [ + known[0].id, + ...["vertex", "antigravity", "api", "oauth"].map((prefix) => `${prefix}/${known[0].id}`), + ]; let gets = 0; const baseUrl = await server(t, (_req, res) => { gets++; @@ -1877,7 +2028,7 @@ test("qualified Gemini adapters retain thinking, tool IDs, strict sampling, imag await run(source.id, history(source.id), ({ model: _model, ...payload }) => { baseline = JSON.parse(JSON.stringify(payload)); }); - for (const prefix of ["vertex", "antigravity"]) { + for (const prefix of ["vertex", "antigravity", "api", "oauth"]) { const id = `${prefix}/${source.id}`; await run(id, history(id), ({ model: _model, ...payload }) => { assert.deepEqual(JSON.parse(JSON.stringify(payload)), baseline); @@ -1888,7 +2039,12 @@ test("qualified Gemini adapters retain thinking, tool IDs, strict sampling, imag } if (source.id.startsWith("gemini-2")) assert.match(JSON.stringify(payload), /Tool result image:/); }); - for (const previous of [source.id, `${prefix === "vertex" ? "antigravity" : "vertex"}/${source.id}`]) { + for (const previous of [ + source.id, + ...["vertex", "antigravity", "api", "oauth"] + .filter((other) => other !== prefix) + .map((other) => `${other}/${source.id}`), + ]) { await run(id, history(previous), (payload) => assert.ok(!JSON.stringify(payload).includes(signature)), ); @@ -1897,6 +2053,28 @@ test("qualified Gemini adapters retain thinking, tool IDs, strict sampling, imag } }); +test("previous auth-route policy cannot restore prefixed media as chat aliases", async (t) => { + t.mock.method(globalThis, "fetch", () => assert.fail("Offline restoration must not contact the proxy")); + const id = "api/gpt-image-1.5"; + const config = parseConfig({ aliases: { [id]: "openai/gpt-fixture" } }); + const provider = createCliproxyProvider(config, known); + await provider.refreshModels({ + allowNetwork: false, + signal: AbortSignal.timeout(5000), + stored: { + etag: createHash("sha256") + .update(JSON.stringify([4, { baseUrl: config.baseUrl, aliases: config.aliases }])) + .digest("hex"), + models: [{ ...known[1], id, provider: PROVIDER_ID, headers: undefined }], + }, + async publish(publication) { + publication.update?.(); + return true; + }, + }); + assert.deepEqual(provider.getModels(), []); +}); + test("previous-policy caches cannot restore GPT-image chat aliases", async (t) => { t.mock.method(globalThis, "fetch", () => assert.fail("Offline restoration must not contact the proxy")); for (const id of [