From f453bc5f274439676b81f790790843819d758105 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 28 Sep 2026 03:16:11 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20useProviderMod?= =?UTF-8?q?elsWithPlanFilter=20=E8=AE=BE=E7=BD=AE=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E6=A0=B9=E6=8D=AE=E5=A5=97=E9=A4=90=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=87=BA=E5=8F=AF=E7=94=A8=E7=9A=84=E6=A8=A1=E5=9E=8B=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + README_zh.md | 1 + proxy.mjs | 124 ++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ec0a6c3..36e7e87 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ commandcode/ | `logFile` | `""` | Log file path (empty = console only) | | `logLevel` | `info` | Log level | | `useProviderModels` | `true` | Dynamically fetch model list from Provider API | +| `useProviderModelsWithPlanFilter` | `false` | Filter models by the subscription plan for the current API key | | `modelRefreshIntervalMs` | `300000` | Model list cache refresh interval (5 min) | | `zdr` | `false` | Request ZDR-only routing from Command Code | | `cliMode` | `agent` | Envelope `mode`. Upstream enum: `agent` / `learning` / `custom-agent` / `custom-agent-create` / `title-gen` / `tool-desc` / `compact` / `vision` | diff --git a/README_zh.md b/README_zh.md index 8e7c5a5..934eded 100644 --- a/README_zh.md +++ b/README_zh.md @@ -59,6 +59,7 @@ commandcode/ | `logFile` | `""` | 日志文件路径(空=仅控制台) | | `logLevel` | `info` | 日志级别 | | `useProviderModels` | `true` | 从 Provider API 动态拉取模型列表 | +| `useProviderModelsWithPlanFilter` | `false` | 使用当前 API Key 的订阅套餐过滤模型列表 | | `modelRefreshIntervalMs` | `300000` | 模型列表缓存刷新间隔(5min) | | `zdr` | `false` | 请求 Command Code 使用 ZDR-only 路由 | | `cliMode` | `agent` | 信封 `mode`。上游枚举:`agent` / `learning` / `custom-agent` / `custom-agent-create` / `title-gen` / `tool-desc` / `compact` / `vision` | diff --git a/proxy.mjs b/proxy.mjs index 8448887..f5f4d5a 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -24,6 +24,7 @@ function loadConfig() { logFile: '', logLevel: 'info', useProviderModels: true, + useProviderModelsWithPlanFilter: false, modelRefreshIntervalMs: 5 * 60 * 1000, // 5 minutes zdr: false, cliMode: 'agent', // 信封 mode。服务端枚举(真机 400 报出来的):agent|learning|custom-agent|custom-agent-create|title-gen|tool-desc|compact|vision @@ -2576,46 +2577,115 @@ async function handleMessages(req, res) { // ── 动态模型列表 ──────────────────────────────────── +function isPricingRow(value) { + return value && typeof value === 'object' && !Array.isArray(value) + && typeof value.id === 'string' + && value.availability && typeof value.availability === 'object' + && !Array.isArray(value.availability); +} + +function findPricingRows(value) { + if (Array.isArray(value)) { + for (const child of value) { + const nested = findPricingRows(child); + if (nested.length) return nested; + } + } else if (value && typeof value === 'object') { + if (Array.isArray(value.rows)) { + const rows = value.rows.filter(isPricingRow); + if (rows.length === value.rows.length && rows.length) return rows; + } + for (const child of Object.values(value)) { + const nested = findPricingRows(child); + if (nested.length) return nested; + } + } + return []; +} + +function extractPricingRows(rsc) { + for (const line of rsc.split(/\r?\n/)) { + const separator = line.indexOf(':'); + if (separator < 0) continue; + try { + const rows = findPricingRows(JSON.parse(line.slice(separator + 1))); + if (rows.length) return rows; + } catch { /* RSC also contains non-JSON control records. */ } + } + return []; +} + +function filterModelsByPlan(models, planId, rows) { + if (!planId) return models; + const availability = new Map( + rows + .filter(isPricingRow) + .map(row => [row.id, row.availability]), + ); + return models.filter(model => availability.get(model.id)?.[planId] !== false); +} + let dynamicModels = null; let modelsLastFetch = 0; async function fetchModels(apiKey) { const now = Date.now(); - if (dynamicModels && (now - modelsLastFetch) < CFG.modelRefreshIntervalMs) { - return dynamicModels; + if (!dynamicModels || (now - modelsLastFetch) >= CFG.modelRefreshIntervalMs) { + try { + if (!apiKey || !CFG.useProviderModels) throw new Error('Provider models disabled'); + + const response = await upstreamFetch(`${CFG.apiBase}/provider/v1/models`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'x-cli-environment': 'production', + 'x-command-code-version': CC_VERSION, + }, + signal: AbortSignal.timeout(10000), + }); + + if (response.ok) { + const data = await response.json(); + if (Array.isArray(data.data)) { + dynamicModels = data.data.map(m => ({ + id: m.id, + name: m.id, + })); + modelsLastFetch = now; + log('info', 'Fetched models from Provider API', { count: dynamicModels.length }); + } + } + if (dynamicModels) return applyPlanFilter(dynamicModels, apiKey); + log('warn', 'Provider models fetch failed, using hardcoded list', { status: response.status }); + } catch (e) { + log('warn', 'Provider models fetch error, using hardcoded list', { error: e.message }); + } } - try { - if (!apiKey || !CFG.useProviderModels) throw new Error('Provider models disabled'); + return applyPlanFilter(dynamicModels || MODELS, apiKey); +} - const response = await upstreamFetch(`${CFG.apiBase}/provider/v1/models`, { - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'x-cli-environment': 'production', - 'x-command-code-version': CC_VERSION, - }, +async function applyPlanFilter(models, apiKey) { + if (!CFG.useProviderModelsWithPlanFilter || !apiKey) return models; + try { + const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { + headers: { 'x-api-key': apiKey }, signal: AbortSignal.timeout(10000), }); + if (!subscription.ok) return models; + const subscriptionBody = await subscription.json(); + const planId = subscriptionBody?.data?.planId; + if (typeof planId !== 'string' || !planId) return models; - if (response.ok) { - const data = await response.json(); - if (Array.isArray(data.data)) { - dynamicModels = data.data.map(m => ({ - id: m.id, - name: m.id, - })); - modelsLastFetch = now; - log('info', 'Fetched models from Provider API', { count: dynamicModels.length }); - return dynamicModels; - } - } - log('warn', 'Provider models fetch failed, using hardcoded list', { status: response.status }); + const pricing = await upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { + headers: { rsc: '1' }, + signal: AbortSignal.timeout(10000), + }); + if (!pricing.ok) return models; + return filterModelsByPlan(models, planId, extractPricingRows(await pricing.text())); } catch (e) { - log('warn', 'Provider models fetch error, using hardcoded list', { error: e.message }); + log('warn', 'Plan model filter failed, using unfiltered list', { error: e.message }); + return models; } - - // Fallback to hardcoded MODELS - return MODELS; } // ── OpenAI Responses API(/v1/responses)────────────── From 2cb67b7438d644bec777538db52d3da9b6e698ee Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 28 Sep 2026 03:58:31 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=20provider=20?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=8F=AF=E8=83=BD=E4=B8=8D=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index f5f4d5a..c98e584 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2617,12 +2617,13 @@ function extractPricingRows(rsc) { function filterModelsByPlan(models, planId, rows) { if (!planId) return models; + const modelKey = id => String(id).split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, ''); const availability = new Map( rows .filter(isPricingRow) - .map(row => [row.id, row.availability]), + .map(row => [modelKey(row.id), row.availability]), ); - return models.filter(model => availability.get(model.id)?.[planId] !== false); + return models.filter(model => availability.get(modelKey(model.id))?.[planId] !== false); } let dynamicModels = null; From 7470e28db5862a0b9b423062d7f5a20adc958566 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 28 Sep 2026 04:05:45 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TTL=20=E5=86=85?= =?UTF-8?q?=E4=BB=8D=E7=84=B6=E8=AF=B7=E6=B1=82=E4=B8=8A=E6=B8=B8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.mjs | 71 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index c98e584..cc0a534 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2628,6 +2628,8 @@ function filterModelsByPlan(models, planId, rows) { let dynamicModels = null; let modelsLastFetch = 0; +const planFilterCache = new Map(); +const planFilterInFlight = new Map(); async function fetchModels(apiKey) { const now = Date.now(); @@ -2665,28 +2667,57 @@ async function fetchModels(apiKey) { return applyPlanFilter(dynamicModels || MODELS, apiKey); } +async function fetchPlanFilter(apiKey) { + const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { + headers: { 'x-api-key': apiKey }, + signal: AbortSignal.timeout(10000), + }); + if (!subscription.ok) return null; + const subscriptionBody = await subscription.json(); + const planId = subscriptionBody?.data?.planId; + if (typeof planId !== 'string' || !planId) return null; + + const pricing = await upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { + headers: { rsc: '1' }, + signal: AbortSignal.timeout(10000), + }); + if (!pricing.ok) return null; + return { planId, rows: extractPricingRows(await pricing.text()) }; +} + async function applyPlanFilter(models, apiKey) { if (!CFG.useProviderModelsWithPlanFilter || !apiKey) return models; - try { - const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { - headers: { 'x-api-key': apiKey }, - signal: AbortSignal.timeout(10000), - }); - if (!subscription.ok) return models; - const subscriptionBody = await subscription.json(); - const planId = subscriptionBody?.data?.planId; - if (typeof planId !== 'string' || !planId) return models; - - const pricing = await upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { - headers: { rsc: '1' }, - signal: AbortSignal.timeout(10000), - }); - if (!pricing.ok) return models; - return filterModelsByPlan(models, planId, extractPricingRows(await pricing.text())); - } catch (e) { - log('warn', 'Plan model filter failed, using unfiltered list', { error: e.message }); - return models; - } + const now = Date.now(); + const cached = planFilterCache.get(apiKey); + if (cached && cached.expiresAt > now) { + return cached.data ? filterModelsByPlan(models, cached.data.planId, cached.data.rows) : models; + } + + let pending = planFilterInFlight.get(apiKey); + if (!pending) { + pending = fetchPlanFilter(apiKey) + .catch(e => { + log('warn', 'Plan model filter failed, using unfiltered list', { error: e.message }); + return null; + }) + .then(data => { + const expiresAt = Date.now() + CFG.modelRefreshIntervalMs; + planFilterCache.set(apiKey, { + data, + expiresAt, + }); + const timer = setTimeout(() => { + if (planFilterCache.get(apiKey)?.expiresAt === expiresAt) planFilterCache.delete(apiKey); + }, CFG.modelRefreshIntervalMs); + timer.unref?.(); + return data; + }) + .finally(() => planFilterInFlight.delete(apiKey)); + planFilterInFlight.set(apiKey, pending); + } + + const data = await pending; + return data ? filterModelsByPlan(models, data.planId, data.rows) : models; } // ── OpenAI Responses API(/v1/responses)────────────── From 768a67d4f534e3121157d35dadf5276b7f507377 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 28 Sep 2026 05:10:48 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E5=90=8C=E4=B8=80=20API=20Key=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=AF=B7=E6=B1=82=E5=8F=AA=E5=8F=91=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E8=AE=A2=E9=98=85=E8=AF=B7=E6=B1=82=EF=BC=8CRSC=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=85=A8=E5=B1=80=E7=BC=93=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8=E5=8C=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.mjs | 53 ++++++++++++++---- test/models.test.mjs | 108 ++++++++++++++++++++++++++++++++++++ test/pricing-fetch-mock.cjs | 16 ++++++ 3 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 test/models.test.mjs create mode 100644 test/pricing-fetch-mock.cjs diff --git a/proxy.mjs b/proxy.mjs index cc0a534..6037901 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2630,6 +2630,8 @@ let dynamicModels = null; let modelsLastFetch = 0; const planFilterCache = new Map(); const planFilterInFlight = new Map(); +let pricingRowsCache = null; +let pricingRowsInFlight = null; async function fetchModels(apiKey) { const now = Date.now(); @@ -2667,7 +2669,7 @@ async function fetchModels(apiKey) { return applyPlanFilter(dynamicModels || MODELS, apiKey); } -async function fetchPlanFilter(apiKey) { +async function fetchPlanId(apiKey) { const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { headers: { 'x-api-key': apiKey }, signal: AbortSignal.timeout(10000), @@ -2675,14 +2677,42 @@ async function fetchPlanFilter(apiKey) { if (!subscription.ok) return null; const subscriptionBody = await subscription.json(); const planId = subscriptionBody?.data?.planId; - if (typeof planId !== 'string' || !planId) return null; + return typeof planId === 'string' && planId ? planId : null; +} + +async function fetchPricingRows() { + const now = Date.now(); + if (pricingRowsCache && pricingRowsCache.expiresAt > now) return pricingRowsCache.data; + if (pricingRowsInFlight) return pricingRowsInFlight; - const pricing = await upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { + pricingRowsInFlight = upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { headers: { rsc: '1' }, signal: AbortSignal.timeout(10000), - }); - if (!pricing.ok) return null; - return { planId, rows: extractPricingRows(await pricing.text()) }; + }) + .then(async pricing => { + if (!pricing.ok) return null; + const rows = extractPricingRows(await pricing.text()); + if (!rows.length) { + log('warn', 'Pricing model rows not recognized, using unfiltered list'); + return null; + } + return rows; + }) + .catch(e => { + log('warn', 'Pricing model fetch failed, using unfiltered list', { error: e.message }); + return null; + }) + .then(data => { + const expiresAt = Date.now() + CFG.modelRefreshIntervalMs; + pricingRowsCache = { data, expiresAt }; + const timer = setTimeout(() => { + if (pricingRowsCache?.expiresAt === expiresAt) pricingRowsCache = null; + }, CFG.modelRefreshIntervalMs); + timer.unref?.(); + return data; + }) + .finally(() => { pricingRowsInFlight = null; }); + return pricingRowsInFlight; } async function applyPlanFilter(models, apiKey) { @@ -2690,12 +2720,13 @@ async function applyPlanFilter(models, apiKey) { const now = Date.now(); const cached = planFilterCache.get(apiKey); if (cached && cached.expiresAt > now) { - return cached.data ? filterModelsByPlan(models, cached.data.planId, cached.data.rows) : models; + const rows = cached.data && await fetchPricingRows(); + return cached.data && rows ? filterModelsByPlan(models, cached.data, rows) : models; } let pending = planFilterInFlight.get(apiKey); if (!pending) { - pending = fetchPlanFilter(apiKey) + pending = fetchPlanId(apiKey) .catch(e => { log('warn', 'Plan model filter failed, using unfiltered list', { error: e.message }); return null; @@ -2716,8 +2747,10 @@ async function applyPlanFilter(models, apiKey) { planFilterInFlight.set(apiKey, pending); } - const data = await pending; - return data ? filterModelsByPlan(models, data.planId, data.rows) : models; + const planId = await pending; + if (!planId) return models; + const rows = await fetchPricingRows(); + return rows ? filterModelsByPlan(models, planId, rows) : models; } // ── OpenAI Responses API(/v1/responses)────────────── diff --git a/test/models.test.mjs b/test/models.test.mjs new file mode 100644 index 0000000..4026d6d --- /dev/null +++ b/test/models.test.mjs @@ -0,0 +1,108 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startMockUpstream, startProxy } from './helpers.mjs'; + +const MODELS = [ + { id: 'google/gemini-3.8-flash' }, + { id: 'unknown-model' }, +]; +const MOCK = join(dirname(fileURLToPath(import.meta.url)), 'pricing-fetch-mock.cjs'); + +async function startModelProxy({ pricingStatus = 200 } = {}) { + const calls = []; + const upstream = await startMockUpstream({ + onRequest: async (req, res) => { + calls.push(req.url); + if (req.url === '/provider/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: MODELS })); + } else if (req.url === '/alpha/billing/subscriptions') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { planId: 'individual-go' } })); + } + }, + }); + const cwd = mkdtempSync(join(tmpdir(), 'ccp-models-')); + const counter = join(cwd, 'pricing-calls.log'); + writeFileSync(join(cwd, 'config.json'), JSON.stringify({ + useProviderModelsWithPlanFilter: true, + modelRefreshIntervalMs: 60000, + })); + const proxy = await startProxy({ + upstreamPort: upstream.port, + env: { + CC_USE_PROVIDER_MODELS: 'true', + CC_TEST_PRICING_STATUS: String(pricingStatus), + CC_TEST_PRICING_COUNTER_FILE: counter, + NODE_OPTIONS: `--require=${MOCK}`, + }, + cwd, + }); + return { + proxy, + calls, + pricingCalls: () => existsSync(counter) ? readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0, + resetPricingCalls: () => writeFileSync(counter, ''), + async close() { + await proxy.kill(); + await upstream.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + }; +} + +async function modelIds(proxy, key = 'user_test') { + const response = await proxy.get('/v1/models', { + headers: { Authorization: `Bearer ${key}` }, + }); + assert.equal(response.status, 200); + return (await response.json()).data.map(model => model.id); +} + +test('v1/models filters denied rows and keeps models missing from pricing', async () => { + const s = await startModelProxy(); + try { + assert.deepEqual(await modelIds(s.proxy), ['unknown-model']); + } finally { await s.close(); } +}); + +test('v1/models fails open when pricing is unavailable', async () => { + const s = await startModelProxy({ pricingStatus: 503 }); + try { + assert.deepEqual(await modelIds(s.proxy), MODELS.map(model => model.id)); + } finally { await s.close(); } +}); + +test('concurrent keys share one pricing refresh but fetch subscriptions separately', async () => { + const s = await startModelProxy(); + try { + await Promise.all([modelIds(s.proxy, 'user_one'), modelIds(s.proxy, 'user_two')]); + assert.equal(s.calls.filter(url => url === '/alpha/billing/subscriptions').length, 2); + assert.equal(s.pricingCalls(), 1); + } finally { await s.close(); } +}); + +test('concurrent requests for one key share the subscription refresh', async () => { + const s = await startModelProxy(); + try { + await Promise.all([modelIds(s.proxy), modelIds(s.proxy)]); + assert.equal(s.calls.filter(url => url === '/alpha/billing/subscriptions').length, 1); + assert.equal(s.pricingCalls(), 1); + } finally { await s.close(); } +}); + +test('same key reuses subscription and pricing data within the TTL', async () => { + const s = await startModelProxy(); + try { + await modelIds(s.proxy); + s.calls.length = 0; + s.resetPricingCalls(); + await Promise.all([modelIds(s.proxy), modelIds(s.proxy)]); + assert.equal(s.calls.filter(url => url === '/alpha/billing/subscriptions').length, 0); + assert.equal(s.pricingCalls(), 0); + } finally { await s.close(); } +}); diff --git a/test/pricing-fetch-mock.cjs b/test/pricing-fetch-mock.cjs new file mode 100644 index 0000000..5d89870 --- /dev/null +++ b/test/pricing-fetch-mock.cjs @@ -0,0 +1,16 @@ +const fs = require('node:fs'); +const originalFetch = globalThis.fetch; +const PRICING_URL = 'https://commandcode.ai/docs/resources/pricing-limits'; +const PRICING_RSC = 'segment:["x",null,{"rows":[{"id":"gemini-3.8-flash","availability":{"individual-go":false}}]}]'; + +globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input?.url; + if (url !== PRICING_URL) return originalFetch(input, init); + const counter = process.env.CC_TEST_PRICING_COUNTER_FILE; + if (counter) fs.appendFileSync(counter, '1\n'); + const status = Number(process.env.CC_TEST_PRICING_STATUS || 200); + return new Response(status === 200 ? PRICING_RSC : 'pricing unavailable', { + status, + headers: { 'Content-Type': 'text/x-component' }, + }); +}; From 82ff5f7d9aad391ba3957a7cd63a6927c3e7e624 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 28 Sep 2026 21:30:48 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20name=20=E5=8C=B9=E9=85=8D=EF=BC=8C=E5=85=BC=E5=AE=B9=20claud?= =?UTF-8?q?e-haiku-4-5=20=E8=BF=99=E6=A0=B7=E5=8F=AF=E8=83=BD=E5=B0=BE?= =?UTF-8?q?=E7=BC=80=E4=B8=8D=E5=90=8C=E7=9A=84=E6=A8=A1=E5=9E=8B=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.mjs | 14 +++++++------- test/models.test.mjs | 1 + test/pricing-fetch-mock.cjs | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index 6037901..0d6ed91 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2618,12 +2618,12 @@ function extractPricingRows(rsc) { function filterModelsByPlan(models, planId, rows) { if (!planId) return models; const modelKey = id => String(id).split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, ''); - const availability = new Map( - rows - .filter(isPricingRow) - .map(row => [modelKey(row.id), row.availability]), - ); - return models.filter(model => availability.get(modelKey(model.id))?.[planId] !== false); + const keysFor = item => [item.id, item.name].filter(Boolean).map(modelKey); + const availability = new Map(); + for (const row of rows.filter(isPricingRow)) { + for (const key of keysFor(row)) availability.set(key, row.availability); + } + return models.filter(model => !keysFor(model).some(key => availability.get(key)?.[planId] === false)); } let dynamicModels = null; @@ -2653,7 +2653,7 @@ async function fetchModels(apiKey) { if (Array.isArray(data.data)) { dynamicModels = data.data.map(m => ({ id: m.id, - name: m.id, + name: m.name || m.id, })); modelsLastFetch = now; log('info', 'Fetched models from Provider API', { count: dynamicModels.length }); diff --git a/test/models.test.mjs b/test/models.test.mjs index 4026d6d..d99926c 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -8,6 +8,7 @@ import { startMockUpstream, startProxy } from './helpers.mjs'; const MODELS = [ { id: 'google/gemini-3.8-flash' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, { id: 'unknown-model' }, ]; const MOCK = join(dirname(fileURLToPath(import.meta.url)), 'pricing-fetch-mock.cjs'); diff --git a/test/pricing-fetch-mock.cjs b/test/pricing-fetch-mock.cjs index 5d89870..7a4d9d9 100644 --- a/test/pricing-fetch-mock.cjs +++ b/test/pricing-fetch-mock.cjs @@ -1,7 +1,7 @@ const fs = require('node:fs'); const originalFetch = globalThis.fetch; const PRICING_URL = 'https://commandcode.ai/docs/resources/pricing-limits'; -const PRICING_RSC = 'segment:["x",null,{"rows":[{"id":"gemini-3.8-flash","availability":{"individual-go":false}}]}]'; +const PRICING_RSC = 'segment:["x",null,{"rows":[{"id":"gemini-3.8-flash","availability":{"individual-go":false}},{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","availability":{"individual-go":false}}]}]'; globalThis.fetch = async (input, init) => { const url = typeof input === 'string' ? input : input?.url; From 63571799c4b570e535771255ec6c55d47fe17587 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Mon, 19 Oct 2026 23:51:54 +0800 Subject: [PATCH 6/7] fix CI tests --- proxy.mjs | 9 ++++++++- test/fork.test.mjs | 47 ++++++++++++++++++++++++++------------------ test/helpers.mjs | 5 +++-- test/models.test.mjs | 2 +- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index 4f85130..ac6cdf2 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -1477,9 +1477,16 @@ async function proxyFetch(urlStr, options = {}) { }); } +// Bun 的 node:http 基于 fetch 实现:CONNECT 方法直接报 "fetch() URL is invalid", +// 且 createConnection 被忽略,上面的手搓隧道在 Bun 下无法工作。 +// Bun 原生 fetch 支持 { proxy }(https 目标走 CONNECT,http 目标走 absolute-form 转发, +// URL 中的 user:pass 会转成 Proxy-Authorization),直接用它。 +const IS_BUN = !!process.versions.bun; + /** 上游请求入口:配了代理走隧道,否则用原生 fetch(默认路径行为完全不变)。 */ function upstreamFetch(urlStr, options) { - return UPSTREAM_PROXY ? proxyFetch(urlStr, options) : fetch(urlStr, options); + if (!UPSTREAM_PROXY) return fetch(urlStr, options); + return IS_BUN ? fetch(urlStr, { ...options, proxy: UPSTREAM_PROXY }) : proxyFetch(urlStr, options); } // ── 流式转发 ──────────────────────────────────────── diff --git a/test/fork.test.mjs b/test/fork.test.mjs index 01b450d..731d27d 100644 --- a/test/fork.test.mjs +++ b/test/fork.test.mjs @@ -116,19 +116,28 @@ test('fork: PR #29 兜底 session_id 由请求内容前缀派生(多轮一致 }); // ── issue #18:上游 HTTP(S) 代理(零依赖 CONNECT 隧道)── -// Bun 的 node:http 基于 fetch 实现:CONNECT 方法发起即报 "fetch() URL is invalid", -// 且 http.request 的 createConnection 会被忽略(自建连接直连),隧道无法落地。 -// 所以两个「必须走隧道」的用例只在 Node 跑;「不走代理」的负向断言两个运行器都成立。 -const IS_BUN = !!process.versions.bun; -const notOnBun = IS_BUN ? test.skip : test; +// Node 下走手搓 CONNECT 隧道;Bun 的 node:http 不支持 CONNECT,代理改用 Bun 原生 +// fetch({ proxy }):https 目标同样是 CONNECT,但 http 目标(本测试的 loopback mock) +// 按规范走 absolute-form 转发。录制代理两种都接,统一记入 hits。 -/** 录制型 CONNECT 代理:记录每次 CONNECT 的 target,并做裸字节转发。 */ +/** 录制型代理:记录每次经代理的请求(CONNECT target 或 absolute-form URL),并转发。 */ async function startRecordingProxy() { const port = await allocPort(); - const connects = []; - const server = http.createServer((req, res) => { res.writeHead(405); res.end(); }); + const hits = []; + const server = http.createServer((req, res) => { + let target; + try { target = new URL(req.url); } catch { res.writeHead(405); res.end(); return; } + hits.push(req.url); + const up = http.request({ host: target.hostname, port: target.port || 80, + path: target.pathname + target.search, method: req.method, headers: req.headers }, (ur) => { + res.writeHead(ur.statusCode, ur.headers); + ur.pipe(res); + }); + up.on('error', () => res.destroy()); + req.pipe(up); + }); server.on('connect', (req, clientSocket, head) => { - connects.push(req.url); + hits.push(req.url); const [h, p] = req.url.split(':'); const up = net.connect(Number(p || 443), h, () => { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); @@ -139,10 +148,10 @@ async function startRecordingProxy() { clientSocket.on('error', () => up.destroy()); }); await new Promise(r => server.listen(port, '127.0.0.1', r)); - return { port, connects, close: () => new Promise(r => server.close(r)) }; + return { port, hits, close: () => new Promise(r => server.close(r)) }; } -notOnBun('#18: 配置 CC_UPSTREAM_PROXY 后上游请求经 CONNECT 隧道', async () => { +test('#18: 配置 CC_UPSTREAM_PROXY 后上游请求经代理', async () => { const rec = await startRecordingProxy(); const mock = await startMockUpstream(); const proxy = await startProxy({ upstreamPort: mock.port, @@ -150,7 +159,7 @@ notOnBun('#18: 配置 CC_UPSTREAM_PROXY 后上游请求经 CONNECT 隧道', asyn try { const r = await proxy.post('/v1/chat/completions', CHAT, AUTH); await r.text(); - assert.ok(rec.connects.length >= 1, '应建立 CONNECT 隧道,实际 ' + JSON.stringify(rec.connects)); + assert.ok(rec.hits.length >= 1, '上游请求应经代理,实际 ' + JSON.stringify(rec.hits)); // generate + fingerprint + lifecycle 三条都要走代理 assert.equal(mock.generateCount(), 1, 'generate 应经隧道到达上游'); assert.equal(mock.lastGenerate().headers['user-agent'], 'cli', '经代理时 UA 仍为 cli'); @@ -159,7 +168,7 @@ notOnBun('#18: 配置 CC_UPSTREAM_PROXY 后上游请求经 CONNECT 隧道', asyn } }); -notOnBun('#18: 预请求也走代理(避免同一账号从两个 IP 注册)', async () => { +test('#18: 预请求也走代理(避免同一账号从两个 IP 注册)', async () => { const rec = await startRecordingProxy(); const mock = await startMockUpstream(); const proxy = await startProxy({ upstreamPort: mock.port, @@ -167,9 +176,9 @@ notOnBun('#18: 预请求也走代理(避免同一账号从两个 IP 注册)' try { const r = await proxy.post('/v1/chat/completions', CHAT, AUTH); await r.text(); - // 三条预请求 + generate 共 3 次 CONNECT(generate/fingerprint/lifecycle) - assert.ok(rec.connects.length >= 3, - 'fingerprint 与 lifecycle 也必须走代理,否则账号会从两个 IP 注册。实际 CONNECT 数: ' + rec.connects.length); + // 三条预请求 + generate 共 3 次经代理(generate/fingerprint/lifecycle) + assert.ok(rec.hits.length >= 3, + 'fingerprint 与 lifecycle 也必须走代理,否则账号会从两个 IP 注册。实际代理请求数: ' + rec.hits.length); } finally { await proxy.kill(); await mock.close(); await rec.close(); } @@ -182,7 +191,7 @@ test('#18: 未配置代理时不建立任何 CONNECT', async () => { try { const r = await proxy.post('/v1/chat/completions', CHAT, AUTH); await r.text(); - assert.equal(rec.connects.length, 0, '默认应直连,不经任何代理'); + assert.equal(rec.hits.length, 0, '默认应直连,不经任何代理'); } finally { await proxy.kill(); await mock.close(); await rec.close(); } @@ -194,10 +203,10 @@ test('#18: 探活端点不经过上游代理', async () => { const proxy = await startProxy({ upstreamPort: mock.port, env: { CC_UPSTREAM_PROXY: 'http://127.0.0.1:' + rec.port } }); try { - const before = rec.connects.length; + const before = rec.hits.length; const r = await proxy.get('/health'); assert.equal(r.status, 200); - assert.equal(rec.connects.length, before, '/health 是本地端点,不应触发上游 CONNECT'); + assert.equal(rec.hits.length, before, '/health 是本地端点,不应触发上游 CONNECT'); } finally { await proxy.kill(); await mock.close(); await rec.close(); } diff --git a/test/helpers.mjs b/test/helpers.mjs index 5feb59b..ecca8a1 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -96,7 +96,8 @@ export async function startMockUpstream(opts = {}) { } /** 在临时 cwd 中启动代理(复刻真实部署:proxy.mjs 与 config.json 同目录)。 */ -export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { +// execArgv 用于预加载 mock(--require):NODE_OPTIONS 在 Bun 下不生效,命令行参数两边都认。 +export async function startProxy({ upstreamPort, env = {}, cwd, execArgv = [] } = {}) { const port = await allocPort(); const logs = []; // 自建的临时工作目录用完必须删;调用方传了 cwd 则由调用方负责。 @@ -106,7 +107,7 @@ export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { if (!existsSync(join(workdir, 'config.json'))) { copyFileSync(join(REPO, 'config.json'), join(workdir, 'config.json')); } - const child = spawn(process.execPath, ['proxy.mjs'], { + const child = spawn(process.execPath, [...execArgv, 'proxy.mjs'], { cwd: workdir, env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', CC_API_BASE: 'http://127.0.0.1:' + upstreamPort, diff --git a/test/models.test.mjs b/test/models.test.mjs index d99926c..c3c2215 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -39,8 +39,8 @@ async function startModelProxy({ pricingStatus = 200 } = {}) { CC_USE_PROVIDER_MODELS: 'true', CC_TEST_PRICING_STATUS: String(pricingStatus), CC_TEST_PRICING_COUNTER_FILE: counter, - NODE_OPTIONS: `--require=${MOCK}`, }, + execArgv: ['--require', MOCK], cwd, }); return { From 8f565eb4a8389773f3148bb9547b6b6b1c2cd736 Mon Sep 17 00:00:00 2001 From: TGSAN Date: Tue, 20 Oct 2026 00:29:13 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=B8=8D=E7=BC=93=E5=AD=98=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=20plan=20=E8=AF=B7=E6=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BD=BF=E7=94=A8=E8=BF=87=E6=9C=9F=E7=9A=84?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E9=98=B2=E6=AD=A2=E5=AE=8C=E6=95=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E9=9C=B2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy.mjs | 191 ++++++++++++++++++++---------------- test/models.test.mjs | 72 +++++++++++++- test/pricing-fetch-mock.cjs | 12 ++- 3 files changed, 188 insertions(+), 87 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index ac6cdf2..1772393 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -2982,10 +2982,110 @@ function filterModelsByPlan(models, planId, rows) { let dynamicModels = null; let modelsLastFetch = 0; -const planFilterCache = new Map(); +// 套餐过滤所需的两份数据(订阅 planId、pricing 表)只缓存成功结果。 +// 刷新失败先重试;重试仍失败则降级用最后一次成功的结果(即使已过期), +// 从没成功过才返回 null(调用方据此不过滤,输出全量)。失败本身不缓存,下次请求会再试。 +const planFilterCache = new Map(); // apiKey → { data: planId|null, expiresAt } const planFilterInFlight = new Map(); -let pricingRowsCache = null; -let pricingRowsInFlight = null; +const pricingRowsState = { cache: null, inFlight: null }; +const PLAN_FILTER_ATTEMPTS = 3; +const PLAN_FILTER_RETRY_BASE_MS = 300; + +/** 不值得重试的失败(如 401/403、页面结构变了):重试也只会得到同样结果。 */ +class NonRetryableError extends Error {} + +async function withRetry(load) { + for (let attempt = 1; ; attempt++) { + try { + return await load(); + } catch (e) { + if (e instanceof NonRetryableError || attempt >= PLAN_FILTER_ATTEMPTS) throw e; + await sleep(PLAN_FILTER_RETRY_BASE_MS * 2 ** (attempt - 1)); + } + } +} + +/** + * 读缓存或刷新。entry 由 getEntry/setEntry 存取,并发刷新共用 inFlight。 + * 返回 { data, ok }:ok=false 表示本次刷新失败且没有任何历史成功结果。 + */ +async function cachedRefresh({ what, getEntry, setEntry, getInFlight, setInFlight, load }) { + const entry = getEntry(); + if (entry && entry.expiresAt > Date.now()) return { data: entry.data, ok: true }; + const pending = getInFlight(); + if (pending) return pending; + + const p = withRetry(load) + .then(data => { + setEntry({ data, expiresAt: Date.now() + CFG.modelRefreshIntervalMs }); + return { data, ok: true }; + }) + .catch(e => { + const stale = getEntry(); + if (stale) { + log('warn', `${what} refresh failed, using last known result`, { error: e.message }); + return { data: stale.data, ok: true }; + } + log('warn', `${what} refresh failed, using unfiltered list`, { error: e.message }); + return { data: null, ok: false }; + }) + .finally(() => setInFlight(null)); + setInFlight(p); + return p; +} + +function httpStatusError(what, response) { + try { response.body?.cancel?.(); } catch {} + const msg = `${what} HTTP ${response.status}`; + // 4xx(除 408/429)是确定性失败,不重试 + const s = response.status; + return s >= 400 && s < 500 && s !== 408 && s !== 429 ? new NonRetryableError(msg) : new Error(msg); +} + +async function fetchPlanId(apiKey) { + const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { + headers: { 'x-api-key': apiKey }, + signal: AbortSignal.timeout(10000), + }); + if (!subscription.ok) throw httpStatusError('subscription', subscription); + const subscriptionBody = await subscription.json(); + // 成功响应但没有 planId(如无订阅)是有效结果,照常缓存,不当失败处理 + const planId = subscriptionBody?.data?.planId; + return typeof planId === 'string' && planId ? planId : null; +} + +async function fetchPricingRowsOnce() { + const pricing = await upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { + headers: { rsc: '1' }, + signal: AbortSignal.timeout(10000), + }); + if (!pricing.ok) throw httpStatusError('pricing', pricing); + const rows = extractPricingRows(await pricing.text()); + if (!rows.length) throw new NonRetryableError('pricing model rows not recognized'); + return rows; +} + +function fetchPricingRows() { + return cachedRefresh({ + what: 'Pricing model rows', + getEntry: () => pricingRowsState.cache, + setEntry: entry => { pricingRowsState.cache = entry; }, + getInFlight: () => pricingRowsState.inFlight, + setInFlight: p => { pricingRowsState.inFlight = p; }, + load: fetchPricingRowsOnce, + }); +} + +function fetchPlanIdCached(apiKey) { + return cachedRefresh({ + what: 'Plan subscription', + getEntry: () => planFilterCache.get(apiKey), + setEntry: entry => planFilterCache.set(apiKey, entry), + getInFlight: () => planFilterInFlight.get(apiKey), + setInFlight: p => { if (p) planFilterInFlight.set(apiKey, p); else planFilterInFlight.delete(apiKey); }, + load: () => fetchPlanId(apiKey), + }); +} async function fetchModels(apiKey) { const now = Date.now(); @@ -3023,88 +3123,13 @@ async function fetchModels(apiKey) { return applyPlanFilter(dynamicModels || MODELS, apiKey); } -async function fetchPlanId(apiKey) { - const subscription = await upstreamFetch(`${CFG.apiBase}/alpha/billing/subscriptions`, { - headers: { 'x-api-key': apiKey }, - signal: AbortSignal.timeout(10000), - }); - if (!subscription.ok) return null; - const subscriptionBody = await subscription.json(); - const planId = subscriptionBody?.data?.planId; - return typeof planId === 'string' && planId ? planId : null; -} - -async function fetchPricingRows() { - const now = Date.now(); - if (pricingRowsCache && pricingRowsCache.expiresAt > now) return pricingRowsCache.data; - if (pricingRowsInFlight) return pricingRowsInFlight; - - pricingRowsInFlight = upstreamFetch('https://commandcode.ai/docs/resources/pricing-limits', { - headers: { rsc: '1' }, - signal: AbortSignal.timeout(10000), - }) - .then(async pricing => { - if (!pricing.ok) return null; - const rows = extractPricingRows(await pricing.text()); - if (!rows.length) { - log('warn', 'Pricing model rows not recognized, using unfiltered list'); - return null; - } - return rows; - }) - .catch(e => { - log('warn', 'Pricing model fetch failed, using unfiltered list', { error: e.message }); - return null; - }) - .then(data => { - const expiresAt = Date.now() + CFG.modelRefreshIntervalMs; - pricingRowsCache = { data, expiresAt }; - const timer = setTimeout(() => { - if (pricingRowsCache?.expiresAt === expiresAt) pricingRowsCache = null; - }, CFG.modelRefreshIntervalMs); - timer.unref?.(); - return data; - }) - .finally(() => { pricingRowsInFlight = null; }); - return pricingRowsInFlight; -} - async function applyPlanFilter(models, apiKey) { if (!CFG.useProviderModelsWithPlanFilter || !apiKey) return models; - const now = Date.now(); - const cached = planFilterCache.get(apiKey); - if (cached && cached.expiresAt > now) { - const rows = cached.data && await fetchPricingRows(); - return cached.data && rows ? filterModelsByPlan(models, cached.data, rows) : models; - } - - let pending = planFilterInFlight.get(apiKey); - if (!pending) { - pending = fetchPlanId(apiKey) - .catch(e => { - log('warn', 'Plan model filter failed, using unfiltered list', { error: e.message }); - return null; - }) - .then(data => { - const expiresAt = Date.now() + CFG.modelRefreshIntervalMs; - planFilterCache.set(apiKey, { - data, - expiresAt, - }); - const timer = setTimeout(() => { - if (planFilterCache.get(apiKey)?.expiresAt === expiresAt) planFilterCache.delete(apiKey); - }, CFG.modelRefreshIntervalMs); - timer.unref?.(); - return data; - }) - .finally(() => planFilterInFlight.delete(apiKey)); - planFilterInFlight.set(apiKey, pending); - } - - const planId = await pending; - if (!planId) return models; - const rows = await fetchPricingRows(); - return rows ? filterModelsByPlan(models, planId, rows) : models; + const plan = await fetchPlanIdCached(apiKey); + if (!plan.ok || !plan.data) return models; + const pricing = await fetchPricingRows(); + if (!pricing.ok) return models; + return filterModelsByPlan(models, plan.data, pricing.data); } // ── OpenAI Responses API(/v1/responses)────────────── diff --git a/test/models.test.mjs b/test/models.test.mjs index c3c2215..9a35b11 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -13,8 +13,10 @@ const MODELS = [ ]; const MOCK = join(dirname(fileURLToPath(import.meta.url)), 'pricing-fetch-mock.cjs'); -async function startModelProxy({ pricingStatus = 200 } = {}) { +async function startModelProxy({ pricingStatus = 200, pricingFailFirst = 0, refreshMs = 60000 } = {}) { const calls = []; + // 订阅接口状态码,测试中途可改 + const state = { subscriptionStatus: 200 }; const upstream = await startMockUpstream({ onRequest: async (req, res) => { calls.push(req.url); @@ -22,6 +24,11 @@ async function startModelProxy({ pricingStatus = 200 } = {}) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ data: MODELS })); } else if (req.url === '/alpha/billing/subscriptions') { + if (state.subscriptionStatus !== 200) { + res.writeHead(state.subscriptionStatus, { 'Content-Type': 'application/json' }); + res.end('{"success":false}'); + return; + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: { planId: 'individual-go' } })); } @@ -29,15 +36,18 @@ async function startModelProxy({ pricingStatus = 200 } = {}) { }); const cwd = mkdtempSync(join(tmpdir(), 'ccp-models-')); const counter = join(cwd, 'pricing-calls.log'); + const statusFile = join(cwd, 'pricing-status'); + writeFileSync(statusFile, String(pricingStatus)); writeFileSync(join(cwd, 'config.json'), JSON.stringify({ useProviderModelsWithPlanFilter: true, - modelRefreshIntervalMs: 60000, + modelRefreshIntervalMs: refreshMs, })); const proxy = await startProxy({ upstreamPort: upstream.port, env: { CC_USE_PROVIDER_MODELS: 'true', - CC_TEST_PRICING_STATUS: String(pricingStatus), + CC_TEST_PRICING_STATUS_FILE: statusFile, + CC_TEST_PRICING_FAIL_FIRST: String(pricingFailFirst), CC_TEST_PRICING_COUNTER_FILE: counter, }, execArgv: ['--require', MOCK], @@ -46,6 +56,8 @@ async function startModelProxy({ pricingStatus = 200 } = {}) { return { proxy, calls, + state, + setPricingStatus: status => writeFileSync(statusFile, String(status)), pricingCalls: () => existsSync(counter) ? readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0, resetPricingCalls: () => writeFileSync(counter, ''), async close() { @@ -107,3 +119,57 @@ test('same key reuses subscription and pricing data within the TTL', async () => assert.equal(s.pricingCalls(), 0); } finally { await s.close(); } }); + +const subscriptionCalls = s => s.calls.filter(url => url === '/alpha/billing/subscriptions').length; + +test('pricing refresh retries transient failures before falling back', async () => { + const s = await startModelProxy({ pricingFailFirst: 2 }); + try { + assert.deepEqual(await modelIds(s.proxy), ['unknown-model']); + assert.equal(s.pricingCalls(), 3); + } finally { await s.close(); } +}); + +test('failed pricing refresh is not cached', async () => { + const s = await startModelProxy({ pricingStatus: 503 }); + try { + assert.deepEqual(await modelIds(s.proxy), MODELS.map(model => model.id)); + s.setPricingStatus(200); + assert.deepEqual(await modelIds(s.proxy), ['unknown-model']); + } finally { await s.close(); } +}); + +test('failed subscription refresh is not cached', async () => { + const s = await startModelProxy(); + try { + s.state.subscriptionStatus = 503; + assert.deepEqual(await modelIds(s.proxy), MODELS.map(model => model.id)); + assert.equal(subscriptionCalls(s), 3, '5xx 应重试'); + s.state.subscriptionStatus = 200; + assert.deepEqual(await modelIds(s.proxy), ['unknown-model']); + } finally { await s.close(); } +}); + +test('subscription 4xx is not retried', async () => { + const s = await startModelProxy(); + try { + s.state.subscriptionStatus = 401; + assert.deepEqual(await modelIds(s.proxy), MODELS.map(model => model.id)); + assert.equal(subscriptionCalls(s), 1); + } finally { await s.close(); } +}); + +test('expired cache is used when refresh keeps failing', async () => { + const s = await startModelProxy({ refreshMs: 1000 }); + try { + assert.deepEqual(await modelIds(s.proxy), ['unknown-model']); + await new Promise(r => setTimeout(r, 1200)); + s.state.subscriptionStatus = 503; + s.setPricingStatus(503); + s.calls.length = 0; + s.resetPricingCalls(); + assert.deepEqual(await modelIds(s.proxy), ['unknown-model'], '应降级到过期的旧结果而非全量'); + assert.equal(subscriptionCalls(s), 3, '过期后应尝试刷新'); + assert.equal(s.pricingCalls(), 3); + } finally { await s.close(); } +}); diff --git a/test/pricing-fetch-mock.cjs b/test/pricing-fetch-mock.cjs index 7a4d9d9..1760558 100644 --- a/test/pricing-fetch-mock.cjs +++ b/test/pricing-fetch-mock.cjs @@ -3,12 +3,22 @@ const originalFetch = globalThis.fetch; const PRICING_URL = 'https://commandcode.ai/docs/resources/pricing-limits'; const PRICING_RSC = 'segment:["x",null,{"rows":[{"id":"gemini-3.8-flash","availability":{"individual-go":false}},{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","availability":{"individual-go":false}}]}]'; +// 状态码优先读 CC_TEST_PRICING_STATUS_FILE(测试中途可改),否则读 CC_TEST_PRICING_STATUS。 +// CC_TEST_PRICING_FAIL_FIRST=N:前 N 次请求返回 503,之后按上面的状态码。 +let seen = 0; +function currentStatus() { + const file = process.env.CC_TEST_PRICING_STATUS_FILE; + if (file && fs.existsSync(file)) return Number(fs.readFileSync(file, 'utf8').trim() || 200); + return Number(process.env.CC_TEST_PRICING_STATUS || 200); +} + globalThis.fetch = async (input, init) => { const url = typeof input === 'string' ? input : input?.url; if (url !== PRICING_URL) return originalFetch(input, init); const counter = process.env.CC_TEST_PRICING_COUNTER_FILE; if (counter) fs.appendFileSync(counter, '1\n'); - const status = Number(process.env.CC_TEST_PRICING_STATUS || 200); + seen++; + const status = seen <= Number(process.env.CC_TEST_PRICING_FAIL_FIRST || 0) ? 503 : currentStatus(); return new Response(status === 200 ? PRICING_RSC : 'pricing unavailable', { status, headers: { 'Content-Type': 'text/x-component' },