diff --git a/.github/assets/thinker-logo-dark.svg b/.github/assets/thinker-logo-dark.svg new file mode 100644 index 00000000..5bcaf497 --- /dev/null +++ b/.github/assets/thinker-logo-dark.svg @@ -0,0 +1,5 @@ +Thinker + + + + diff --git a/.github/assets/thinker-logo.svg b/.github/assets/thinker-logo.svg new file mode 100644 index 00000000..d4ce9387 --- /dev/null +++ b/.github/assets/thinker-logo.svg @@ -0,0 +1,5 @@ +Thinker + + + + diff --git a/packages/backend/src/model-discovery/opencode-go-catalog.service.gate.spec.ts b/packages/backend/src/model-discovery/opencode-go-catalog.service.gate.spec.ts new file mode 100644 index 00000000..2810cb09 --- /dev/null +++ b/packages/backend/src/model-discovery/opencode-go-catalog.service.gate.spec.ts @@ -0,0 +1,120 @@ +/** + * #39 / ADR 0003: the OpenCode Go doc lives on raw.githubusercontent.com, so it + * is fetched only when an OpenCode Go provider is registered, and never with + * THINKER_CATALOG_SYNC=false. + */ +import { OpencodeGoCatalogService } from './opencode-go-catalog.service'; + +const MDX = [ + '| Model | Model ID | Endpoint | AI SDK package |', + '| ----- | -------- | -------- | -------------- |', + '| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |', + '', +].join('\n'); + +function providerRepo(count: number | Error) { + const where = jest.fn(() => ({ + getCount: async () => { + if (count instanceof Error) throw count; + return count; + }, + })); + return { + repo: { createQueryBuilder: jest.fn(() => ({ where })) }, + where, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe('OpencodeGoCatalogService egress gate', () => { + let fetchSpy: jest.SpyInstance; + const previous = process.env['THINKER_CATALOG_SYNC']; + + beforeEach(() => { + delete process.env['THINKER_CATALOG_SYNC']; + fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + text: async () => MDX, + } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + if (previous === undefined) delete process.env['THINKER_CATALOG_SYNC']; + else process.env['THINKER_CATALOG_SYNC'] = previous; + }); + + describe('boot warmup', () => { + it('does not fetch when no OpenCode Go provider is registered', async () => { + const { repo } = providerRepo(0); + new OpencodeGoCatalogService(repo as never).onModuleInit(); + await flush(); + expect(repo.createQueryBuilder).toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches once when an OpenCode Go provider is registered', async () => { + const { repo, where } = providerRepo(1); + const service = new OpencodeGoCatalogService(repo as never); + service.onModuleInit(); + await flush(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toContain('raw.githubusercontent.com'); + expect(service.getFormat('glm-5.1')).toBe('openai'); + // Matches the registry id and its aliases, case-insensitively. + expect(where).toHaveBeenCalledWith('LOWER(p.provider) IN (:...keys)', { + keys: expect.arrayContaining(['opencode-go', 'opencodego']), + }); + }); + + it('does not fetch without a repository to check', async () => { + new OpencodeGoCatalogService().onModuleInit(); + await flush(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch when the provider lookup fails', async () => { + const { repo } = providerRepo(new Error('relation "tenant_providers" does not exist')); + new OpencodeGoCatalogService(repo as never).onModuleInit(); + await flush(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('neither queries nor fetches with THINKER_CATALOG_SYNC=false, even with a provider', async () => { + process.env['THINKER_CATALOG_SYNC'] = 'false'; + const { repo } = providerRepo(1); + new OpencodeGoCatalogService(repo as never).onModuleInit(); + await flush(); + expect(repo.createQueryBuilder).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('THINKER_CATALOG_SYNC=false', () => { + beforeEach(() => { + process.env['THINKER_CATALOG_SYNC'] = 'false'; + }); + + it('list() and refresh() return [] without fetching', async () => { + const service = new OpencodeGoCatalogService(); + await expect(service.list()).resolves.toEqual([]); + await expect(service.refresh()).resolves.toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('prices OpenCode Go requests as unknown and leaves the format to the caller heuristic', async () => { + const service = new OpencodeGoCatalogService(); + await expect(service.resolveCostPerRequest('opencode-go/glm-5.1')).resolves.toBeNull(); + await expect(service.resolveFormat('glm-5.1')).resolves.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + it('fetches lazily on first use when sync is on (the path a new provider takes)', async () => { + const service = new OpencodeGoCatalogService(); + await expect(service.resolveFormat('glm-5.1')).resolves.toBe('openai'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/src/model-discovery/opencode-go-catalog.service.malformed.spec.ts b/packages/backend/src/model-discovery/opencode-go-catalog.service.malformed.spec.ts new file mode 100644 index 00000000..a832e5d5 --- /dev/null +++ b/packages/backend/src/model-discovery/opencode-go-catalog.service.malformed.spec.ts @@ -0,0 +1,292 @@ +import { OPENCODE_GO_BUDGET_5H_USD, OpencodeGoCatalogService } from './opencode-go-catalog.service'; + +const BT = String.fromCharCode(96); +const OAI = BT + 'https://opencode.ai/zen/go/v1/chat/completions' + BT; +const ANT = BT + 'https://opencode.ai/zen/go/v1/messages' + BT; +const OAI_SDK = BT + '@ai-sdk/openai-compatible' + BT; +const ANT_SDK = BT + '@ai-sdk/anthropic' + BT; + +const ENDPOINTS_TABLE = [ + '## Endpoints', + '', + '| Model | Model ID | Endpoint | AI SDK Package |', + '| ------------ | ------------ | ------------------------------------------------ | --------------------------- |', + `| GLM-5.1 | glm-5.1 | ${OAI} | ${OAI_SDK} |`, + `| GLM-5 | glm-5 | ${OAI} | ${OAI_SDK} |`, + `| Kimi K2.5 | kimi-k2.5 | ${OAI} | ${OAI_SDK} |`, + `| Qwen3.7 Max | qwen3.7-max | ${ANT} | ${ANT_SDK} |`, + '', +].join('\n'); + +describe('OpencodeGoCatalogService — malformed MDX edge cases', () => { + let service: OpencodeGoCatalogService; + + beforeEach(() => { + service = new OpencodeGoCatalogService(); + }); + + describe('non-numeric request counts in Usage Limits table', () => { + it('skips rows where the request column is "N/A" but keeps other rows', () => { + const mdx = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | N/A | N/A | N/A |', + '| GLM-5 | 1,150 | 2,880 | 5,750 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(mdx); + const cost = Object.fromEntries(entries.map((e) => [e.id, e.costPerRequestUsd])); + + // GLM-5.1 row had "N/A" — regex rejects it, so cost stays null. + expect(cost['glm-5.1']).toBeNull(); + // GLM-5 row had a real number — cost is computed normally. + expect(cost['glm-5']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 1150, 12); + }); + + it('skips rows where the request column is "--" placeholder', () => { + const mdx = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | -- | -- | -- |', + '| GLM-5 | 1,150 | 2,880 | 5,750 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(mdx); + const glm51 = entries.find((e) => e.id === 'glm-5.1'); + const glm5 = entries.find((e) => e.id === 'glm-5'); + + expect(glm51?.costPerRequestUsd).toBeNull(); + expect(glm5?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 1150, 12); + }); + + it('does not crash and still returns all endpoint models when every limits row is non-numeric', () => { + const mdx = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | N/A | N/A | N/A |', + '| GLM-5 | -- | -- | -- |', + '| Kimi K2.5 | TBD | TBD | TBD |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(mdx); + // All 4 endpoint models still returned. + expect(entries.map((e) => e.id).sort()).toEqual([ + 'glm-5', + 'glm-5.1', + 'kimi-k2.5', + 'qwen3.7-max', + ]); + // Every cost is null — no NaN, no Infinity, no crash. + for (const entry of entries) { + expect(entry.costPerRequestUsd).toBeNull(); + } + }); + + it('does not populate the cost index with NaN or Infinity', async () => { + const mdx = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | N/A | N/A | N/A |', + ENDPOINTS_TABLE, + ].join('\n'); + + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + text: async () => mdx, + } as Response); + + try { + await service.list(); + const cost = service.getCostPerRequest('glm-5.1'); + // Must be exactly null — not NaN, not Infinity, not 0. + expect(cost).toBeNull(); + // Sanity: also confirm getFormat still works (only cost was dropped). + expect(service.getFormat('glm-5.1')).toBe('openai'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('skips rows where the request column is empty/whitespace', () => { + const mdx = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | | 2,150 | 4,300 |', + '| GLM-5 | 1,150 | 2,880 | 5,750 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(mdx); + const glm51 = entries.find((e) => e.id === 'glm-5.1'); + const glm5 = entries.find((e) => e.id === 'glm-5'); + + // Empty cell — regex anchored on [0-9] rejects it. + expect(glm51?.costPerRequestUsd).toBeNull(); + expect(glm5?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 1150, 12); + }); + }); + + describe('partially malformed MDX (one table broken)', () => { + it('returns all models with null cost when Usage Limits table is entirely missing', () => { + // Endpoints table valid, no Usage Limits section at all. + const entries = service.parse(ENDPOINTS_TABLE); + expect(entries).toHaveLength(4); + for (const entry of entries) { + expect(entry.costPerRequestUsd).toBeNull(); + } + // IDs still parsed correctly. + expect(entries.map((e) => e.id).sort()).toEqual([ + 'glm-5', + 'glm-5.1', + 'kimi-k2.5', + 'qwen3.7-max', + ]); + }); + + it('returns models with null cost when Usage Limits formatting is broken (no numeric columns)', () => { + // Limits "table" exists but no row matches the 3-numeric-column anchor. + const broken = [ + '## Usage limits', + '', + 'These limits are TBD pending pricing review.', + '', + '- GLM-5.1: see docs', + '- GLM-5: see docs', + '- Kimi K2.5: see docs', + '', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(broken); + expect(entries).toHaveLength(4); + for (const entry of entries) { + expect(entry.costPerRequestUsd).toBeNull(); + } + }); + + it('returns models with null cost when Usage Limits names do not match any Endpoints name', () => { + // Limits table has valid numeric rows, but the names are completely + // different from the endpoints names (e.g. legacy model names). + const mismatch = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------------- | ------------------- | ----------------- | ------------------ |', + '| Legacy Model A | 880 | 2,150 | 4,300 |', + '| Legacy Model B | 1,150 | 2,880 | 5,750 |', + '| Unknown Codename| 1,850 | 4,630 | 9,250 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(mismatch); + // All endpoint models still parsed. + expect(entries).toHaveLength(4); + // None matched — every cost is null, but no crash. + for (const entry of entries) { + expect(entry.costPerRequestUsd).toBeNull(); + } + // IDs still correct. + expect(entries.map((e) => e.id).sort()).toEqual([ + 'glm-5', + 'glm-5.1', + 'kimi-k2.5', + 'qwen3.7-max', + ]); + }); + + it('matches the subset that aligns and leaves the rest null when Endpoints/Limits partially overlap', () => { + // Half the endpoints match Usage Limits rows, half don't. + const partial = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | 880 | 2,150 | 4,300 |', + '| Totally Different Name | 1,150 | 2,880 | 5,750 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(partial); + const cost = Object.fromEntries(entries.map((e) => [e.id, e.costPerRequestUsd])); + + // Only GLM-5.1 matched cross-table. + expect(cost['glm-5.1']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + // Rest of the endpoint models got null, not NaN or crash. + expect(cost['glm-5']).toBeNull(); + expect(cost['kimi-k2.5']).toBeNull(); + expect(cost['qwen3.7-max']).toBeNull(); + }); + + it('returns [] when both tables are malformed beyond recognition', () => { + const garbage = [ + '## Usage limits', + '', + 'totally not a table', + '', + '## Endpoints', + '', + 'also not a table', + ].join('\n'); + + const entries = service.parse(garbage); + expect(entries).toEqual([]); + }); + + it('returns [] when the Endpoints table is missing but Usage Limits is intact', () => { + // Inverse of the above — limits table present, endpoints missing. + // Without Endpoints, there are no models to attribute costs to. + const limitsOnly = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.1 | 880 | 2,150 | 4,300 |', + '| GLM-5 | 1,150 | 2,880 | 5,750 |', + ].join('\n'); + + const entries = service.parse(limitsOnly); + expect(entries).toEqual([]); + }); + + it('still maps format correctly when only some endpoint rows have matching limits', () => { + const partial = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| --------- | ------------------- | ----------------- | ------------------ |', + '| Qwen3.7 Max | 770 | 1,925 | 3,850 |', + ENDPOINTS_TABLE, + ].join('\n'); + + const entries = service.parse(partial); + const byId = Object.fromEntries(entries.map((e) => [e.id, e])); + + // Cost attached only to qwen3.7-max. + expect(byId['qwen3.7-max']?.costPerRequestUsd).toBeCloseTo( + OPENCODE_GO_BUDGET_5H_USD / 770, + 12, + ); + expect(byId['glm-5.1']?.costPerRequestUsd).toBeNull(); + + // But formats still correct for all endpoint rows. + expect(byId['glm-5.1']?.format).toBe('openai'); + expect(byId['glm-5']?.format).toBe('openai'); + expect(byId['kimi-k2.5']?.format).toBe('openai'); + expect(byId['qwen3.7-max']?.format).toBe('anthropic'); + }); + }); +}); diff --git a/packages/backend/src/model-discovery/opencode-go-catalog.service.spec.ts b/packages/backend/src/model-discovery/opencode-go-catalog.service.spec.ts new file mode 100644 index 00000000..61c3473f --- /dev/null +++ b/packages/backend/src/model-discovery/opencode-go-catalog.service.spec.ts @@ -0,0 +1,492 @@ +import { OPENCODE_GO_BUDGET_5H_USD, OpencodeGoCatalogService } from './opencode-go-catalog.service'; + +const BT = String.fromCharCode(96); +const OAI = BT + 'https://opencode.ai/zen/go/v1/chat/completions' + BT; +const ANT = BT + 'https://opencode.ai/zen/go/v1/messages' + BT; +const RESP = BT + 'https://opencode.ai/zen/go/v1/responses' + BT; +const OAI_SDK = BT + '@ai-sdk/openai-compatible' + BT; +const ANT_SDK = BT + '@ai-sdk/anthropic' + BT; +const RESP_SDK = BT + '@ai-sdk/openai' + BT; + +const ENDPOINTS_TABLE = [ + '## Endpoints', + '', + '| Model | Model ID | Endpoint | AI SDK Package |', + '| ------------ | ------------ | ------------------------------------------------ | --------------------------- |', + `| Grok 4.5 | grok-4.5 | ${RESP} | ${RESP_SDK} |`, + `| GLM-5.1 | glm-5.1 | ${OAI} | ${OAI_SDK} |`, + `| GLM-5 | glm-5 | ${OAI} | ${OAI_SDK} |`, + `| Kimi K2.5 | kimi-k2.5 | ${OAI} | ${OAI_SDK} |`, + `| MiMo-V2-Pro | mimo-v2-pro | ${OAI} | ${OAI_SDK} |`, + `| MiMo-V2-Omni | mimo-v2-omni | ${OAI} | ${OAI_SDK} |`, + `| Qwen3.7 Max | qwen3.7-max | ${ANT} | ${ANT_SDK} |`, + `| MiniMax M2.7 | minimax-m2.7 | ${ANT} | ${ANT_SDK} |`, + `| MiniMax M2.5 | minimax-m2.5 | ${ANT} | ${ANT_SDK} |`, + '', +].join('\n'); + +const LIMITS_TABLE = [ + '## Usage limits', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| ------------------ | ------------------- | ----------------- | ------------------ |', + '| Grok 4.5 | 120 | 300 | 600 |', + '| GLM-5.1 | 880 | 2,150 | 4,300 |', + '| GLM-5 | 1,150 | 2,880 | 5,750 |', + '| Kimi K2.5 | 1,850 | 4,630 | 9,250 |', + '| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 |', + '| MiMo-V2-Omni (≤ 256K) | 2,150 | 5,450 | 10,900 |', + '| Qwen3.7 Max | 770 | 1,925 | 3,850 |', + '| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |', + '| MiniMax M2.5 | 6,300 | 15,900 | 31,800 |', + '', +].join('\n'); + +const SAMPLE_MDX = [ + '---', + 'title: Go', + 'description: Low cost subscription for open coding models.', + '---', + '', + LIMITS_TABLE, + ENDPOINTS_TABLE, +].join('\n'); + +describe('OpencodeGoCatalogService', () => { + let service: OpencodeGoCatalogService; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + service = new OpencodeGoCatalogService(); + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + describe('parse', () => { + it('extracts every model in the endpoints table', () => { + const entries = service.parse(SAMPLE_MDX); + expect(entries.map((e) => e.id)).toEqual([ + 'grok-4.5', + 'glm-5.1', + 'glm-5', + 'kimi-k2.5', + 'mimo-v2-pro', + 'mimo-v2-omni', + 'qwen3.7-max', + 'minimax-m2.7', + 'minimax-m2.5', + ]); + }); + + it('keeps the docs display name verbatim', () => { + const entries = service.parse(SAMPLE_MDX); + const labels = Object.fromEntries(entries.map((e) => [e.id, e.displayName])); + expect(labels['grok-4.5']).toBe('Grok 4.5'); + expect(labels['glm-5.1']).toBe('GLM-5.1'); + expect(labels['kimi-k2.5']).toBe('Kimi K2.5'); + expect(labels['mimo-v2-omni']).toBe('MiMo-V2-Omni'); + expect(labels['qwen3.7-max']).toBe('Qwen3.7 Max'); + expect(labels['minimax-m2.7']).toBe('MiniMax M2.7'); + }); + + it('tags docs rows with the endpoint format they declare', () => { + const entries = service.parse(SAMPLE_MDX); + const byId = Object.fromEntries(entries.map((e) => [e.id, e.format])); + expect(byId['grok-4.5']).toBe('responses'); + expect(byId['glm-5.1']).toBe('openai'); + expect(byId['kimi-k2.5']).toBe('openai'); + expect(byId['mimo-v2-pro']).toBe('openai'); + expect(byId['qwen3.7-max']).toBe('anthropic'); + expect(byId['minimax-m2.5']).toBe('anthropic'); + expect(byId['minimax-m2.7']).toBe('anthropic'); + }); + + it('never matches the header row (uppercase model ID column fails regex)', () => { + // The regex anchors the model-id group on [a-z], so "Model ID" in the + // header row column does not match. No explicit skip needed. + const entries = service.parse(SAMPLE_MDX); + expect(entries.find((e) => e.displayName === 'Model')).toBeUndefined(); + expect(entries.find((e) => e.id === 'model id')).toBeUndefined(); + }); + + it('returns an empty array when the table is missing', () => { + expect(service.parse('# Go\n\nNo table here.')).toEqual([]); + }); + + it('deduplicates if a model appears twice', () => { + const doubled = SAMPLE_MDX + '\n' + SAMPLE_MDX; + const entries = service.parse(doubled); + const ids = entries.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('attaches per-request cost derived from the Usage Limits table', () => { + const entries = service.parse(SAMPLE_MDX); + const cost = Object.fromEntries(entries.map((e) => [e.id, e.costPerRequestUsd])); + // $12 / 120 = $0.10 + expect(cost['grok-4.5']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 120, 12); + // $12 / 880 = ~0.01364 + expect(cost['glm-5.1']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + // $12 / 1150 = ~0.01043 + expect(cost['glm-5']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 1150, 12); + // Comma-grouped number — $12 / 6300 = ~0.00190 + expect(cost['minimax-m2.5']).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 6300, 12); + }); + + it('matches names across tables when the limits row has a parenthesized suffix', () => { + const entries = service.parse(SAMPLE_MDX); + const omni = entries.find((e) => e.id === 'mimo-v2-omni'); + expect(omni).toBeDefined(); + // "MiMo-V2-Omni" (endpoints) ↔ "MiMo-V2-Omni (≤ 256K)" (limits) + expect(omni?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 2150, 12); + }); + + it('leaves cost null when the Usage Limits table omits a model', () => { + const onlyEndpoints = ENDPOINTS_TABLE; + const entries = service.parse(onlyEndpoints); + expect(entries.every((e) => e.costPerRequestUsd === null)).toBe(true); + }); + + it('ignores Usage Limits rows whose request count is zero or non-positive', () => { + const broken = [ + '| Bogus | 0 | 0 | 0 |', + '| GLM-5.1 | 880 | 2,150 | 4,300 |', + ENDPOINTS_TABLE, + ].join('\n'); + const entries = service.parse(broken); + const glm = entries.find((e) => e.id === 'glm-5.1'); + expect(glm?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + }); + + it('reads the promoted count when a limits row strikes the old one through', () => { + // Live docs shape: a temporary multiplier strikes the previous count + // through and bolds the new one, and annotates the model name with a + // note. Both cells carry markup the plain number regex misses. + const promo = [ + '| DeepSeek V4.1 Flash
4x \u00b7 Ends Sep 20 | ~~6,500~~
**26,000** | ~~16,250~~
**65,000** | ~~32,500~~
**130,000** |', + '## Endpoints', + '', + `| DeepSeek V4.1 Flash | deepseek-v4.1-flash | ${OAI} | ${OAI_SDK} |`, + ].join('\n'); + const entries = service.parse(promo); + const flash = entries.find((e) => e.id === 'deepseek-v4.1-flash'); + expect(flash?.displayName).toBe('DeepSeek V4.1 Flash'); + expect(flash?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 26000, 12); + }); + + it('reads a limits row that omits its trailing pipe', () => { + const noTrailingPipe = [ + '| GLM-5.1 | 880 | 2,150 | 4,300', + '## Endpoints', + '', + `| GLM-5.1 | glm-5.1 | ${OAI} | ${OAI_SDK} |`, + ].join('\n'); + const entries = service.parse(noTrailingPipe); + expect(entries.find((e) => e.id === 'glm-5.1')?.costPerRequestUsd).toBeCloseTo( + OPENCODE_GO_BUDGET_5H_USD / 880, + 12, + ); + }); + + it('ignores limits rows whose counts are not numbers', () => { + const unlimited = [ + '| Union Alpha Free | Unlimited | Unlimited | Unlimited |', + '## Endpoints', + '', + `| Union Alpha Free | union-alpha | ${ANT} | ${ANT_SDK} |`, + ].join('\n'); + const entries = service.parse(unlimited); + expect(entries.find((e) => e.id === 'union-alpha')?.costPerRequestUsd).toBeNull(); + }); + + it('ignores the token-price table, whose leading cells are dollar amounts', () => { + const prices = [ + '| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | **$60** |', + '## Endpoints', + '', + `| GLM-5.1 | glm-5.1 | ${OAI} | ${OAI_SDK} |`, + ].join('\n'); + const entries = service.parse(prices); + expect(entries.find((e) => e.id === 'glm-5.1')?.costPerRequestUsd).toBeNull(); + }); + + it('uses the first occurrence when the limits table contains duplicates', () => { + const dupLimits = [ + '| GLM-5.1 | 880 | 2,150 | 4,300 |', + '| GLM-5.1 | 1 | 1 | 1 |', + ENDPOINTS_TABLE, + ].join('\n'); + const entries = service.parse(dupLimits); + const glm = entries.find((e) => e.id === 'glm-5.1'); + // First occurrence (880) wins, not the bogus second row. + expect(glm?.costPerRequestUsd).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + }); + }); + + describe('getCostPerRequest', () => { + it('returns null before list() has populated the cache', () => { + expect(service.getCostPerRequest('glm-5.1')).toBeNull(); + }); + + it('returns null for null or empty input', () => { + expect(service.getCostPerRequest(null)).toBeNull(); + expect(service.getCostPerRequest(undefined)).toBeNull(); + expect(service.getCostPerRequest('')).toBeNull(); + }); + + it('resolves both bare and "opencode-go/" prefixed model IDs after list()', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + await service.list(); + const bare = service.getCostPerRequest('glm-5.1'); + const prefixed = service.getCostPerRequest('opencode-go/glm-5.1'); + expect(bare).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + expect(prefixed).toBe(bare); + }); + + it('resolves both bare and prefixed model formats after list()', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + await service.list(); + expect(service.getFormat('qwen3.7-max')).toBe('anthropic'); + expect(service.getFormat('opencode-go/qwen3.7-max')).toBe('anthropic'); + expect(service.getFormat('opencode-go/mimo-v2-pro')).toBe('openai'); + expect(service.getFormat('opencode-go/grok-4.5')).toBe('responses'); + }); + + it('warms the catalog for async format lookup', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + + await expect(service.resolveFormat('opencode-go/qwen3.7-max')).resolves.toBe('anthropic'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns null for a known model with no published limit', async () => { + const endpointsOnly = ['---', 'title: Go', '---', '', ENDPOINTS_TABLE].join('\n'); + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => endpointsOnly, + } as Response); + await service.list(); + expect(service.getCostPerRequest('glm-5.1')).toBeNull(); + }); + + it('returns null for an unknown model id', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + await service.list(); + expect(service.getCostPerRequest('does-not-exist')).toBeNull(); + }); + + it('coalesces concurrent list() calls into a single fetch', async () => { + let resolveFetch!: (r: Response) => void; + fetchSpy.mockImplementation(() => new Promise((res) => (resolveFetch = res))); + + const a = service.list(); + const b = service.list(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + resolveFetch({ ok: true, status: 200, text: async () => SAMPLE_MDX } as Response); + const [ra, rb] = await Promise.all([a, b]); + expect(ra).toBe(rb); + }); + + it('refreshes from the live source even when list() has a warm cache', async () => { + const refreshedMdx = [ + '---', + 'title: Go', + '---', + '', + '| Model | requests per 5 hour | requests per week | requests per month |', + '| ----- | ------------------- | ----------------- | ------------------ |', + '| GLM-5.2 | 880 | 2,150 | 4,300 |', + '', + '| Model | Model ID | Endpoint | AI SDK Package |', + '| ----- | -------- | -------- | -------------- |', + `| GLM-5.2 | glm-5.2 | ${OAI} | ${OAI_SDK} |`, + '', + ].join('\n'); + fetchSpy + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => refreshedMdx, + } as Response); + + await service.list(); + const refreshed = await service.refresh(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(refreshed.map((e) => e.id)).toEqual(['glm-5.2']); + expect(service.getCostPerRequest('glm-5.2')).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + }); + + it('warms the catalog via onModuleInit so the cost index is ready before the first proxy call', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + // The warmup only runs when an OpenCode Go provider is registered. + const providerRepo = { + createQueryBuilder: () => ({ + where: () => ({ getCount: async () => 1 }), + }), + }; + service = new OpencodeGoCatalogService(providerRepo as never); + service.onModuleInit(); + // onModuleInit is fire-and-forget; flush the pending list() promise. + await new Promise((r) => setImmediate(r)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(service.getCostPerRequest('glm-5.1')).not.toBeNull(); + }); + + it('onModuleInit failure is swallowed and leaves the cost index empty', async () => { + fetchSpy.mockRejectedValue(new Error('boom')); + service.onModuleInit(); + await new Promise((r) => setImmediate(r)); + // No throw, no crash — recorder will record $0 for that one call and + // catalog will retry after the error-backoff window. + expect(service.getCostPerRequest('glm-5.1')).toBeNull(); + }); + + it('returns null from resolve for null input without ever hitting list()', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + const result = await service.resolveCostPerRequest(null); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('resolves the cost after awaiting list() when the index is still cold', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + // No onModuleInit, no prior list() — costByModelId is empty. + const cost = await service.resolveCostPerRequest('glm-5.1'); + expect(cost).toBeCloseTo(OPENCODE_GO_BUDGET_5H_USD / 880, 12); + // A second resolution should not re-fetch (cache is warm now). + const again = await service.resolveCostPerRequest('glm-5.1'); + expect(again).toBe(cost); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('is cleared by resetCache()', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + await service.list(); + expect(service.getCostPerRequest('glm-5.1')).not.toBeNull(); + service.resetCache(); + expect(service.getCostPerRequest('glm-5.1')).toBeNull(); + }); + }); + + describe('list', () => { + it('fetches, parses, and caches the catalog', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + + const first = await service.list(); + expect(first).toHaveLength(9); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const second = await service.list(); + expect(second).toBe(first); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns the last good result when a later fetch fails', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => SAMPLE_MDX, + } as Response); + const good = await service.list(); + expect(good).toHaveLength(9); + + // Force the success cache to look expired, but keep lastGood populated. + (service as unknown as { cache: unknown }).cache = null; + + fetchSpy.mockResolvedValueOnce({ ok: false, status: 500 } as Response); + const afterFailure = await service.list(); + expect(afterFailure).toEqual(good); + }); + + it('backs off after a failure so repeated calls do not hammer the network', async () => { + // First fetch fails with nothing cached → returns [] and arms the + // error-backoff window. + fetchSpy.mockResolvedValueOnce({ ok: false, status: 503 } as Response); + const first = await service.list(); + expect(first).toEqual([]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Second call within the backoff window reuses the cached fallback and + // must NOT reach the network. + const second = await service.list(); + expect(second).toEqual([]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns [] when there is no prior cache and the fetch 404s', async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 404 } as Response); + const result = await service.list(); + expect(result).toEqual([]); + }); + + it('returns [] when the fetch throws an Error and nothing is cached', async () => { + fetchSpy.mockRejectedValue(new Error('network down')); + const result = await service.list(); + expect(result).toEqual([]); + }); + + it('returns [] when the fetch throws a non-Error value', async () => { + // Exercises the String(err) fallback when something non-Error is thrown. + fetchSpy.mockRejectedValue('raw string failure'); + const result = await service.list(); + expect(result).toEqual([]); + }); + + it('returns [] (not stale empty) when the docs parse to zero rows', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '# Nothing useful here', + } as Response); + const result = await service.list(); + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/backend/src/model-discovery/opencode-go-catalog.service.ts b/packages/backend/src/model-discovery/opencode-go-catalog.service.ts new file mode 100644 index 00000000..29088bb9 --- /dev/null +++ b/packages/backend/src/model-discovery/opencode-go-catalog.service.ts @@ -0,0 +1,402 @@ +import { Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PROVIDER_BY_ID } from '../common/constants/providers'; +import { describeCatalogSyncSetting, isCatalogSyncEnabled } from '../common/utils/catalog-sync'; +import { TenantProvider } from '../entities/tenant-provider.entity'; +import { outboundFetch } from '../common/outbound/outbound-proxy'; + +export interface OpencodeGoCatalogEntry { + /** Bare model ID as listed in the docs (e.g. "glm-5.1"). */ + id: string; + /** Human-readable display name from the docs (e.g. "GLM-5.1"). */ + displayName: string; + /** Which upstream API format the model expects. */ + format: 'openai' | 'anthropic' | 'responses'; + /** + * Cost in USD attributed to one request, derived from the docs Usage Limits + * table: `OPENCODE_GO_BUDGET_5H_USD / requestsPer5h`. `null` when the docs + * table did not include a row for this model. + */ + costPerRequestUsd: number | null; +} + +const CATALOG_URL = + 'https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/go.mdx'; +const CACHE_TTL_MS = 60 * 60 * 1000; +// After a fetch failure we reuse the last-known-good list for a shorter window +// so a sustained outage does not turn into a per-call retry storm. +const ERROR_BACKOFF_MS = 5 * 60 * 1000; +const FETCH_TIMEOUT_MS = 10_000; + +/** + * Every spelling a `tenant_providers.provider` row can carry for OpenCode Go + * (the registry id plus its aliases), lowercased. + */ +const OPENCODE_GO_PROVIDER_KEYS: readonly string[] = [ + 'opencode-go', + ...(PROVIDER_BY_ID.get('opencode-go')?.aliases ?? []), +].map((key) => key.toLowerCase()); + +/** + * Published 5-hour quota in USD for an OpenCode Go subscription. The docs + * express per-model usage as "requests per 5 hour", so dividing that dollar + * value by the request count gives the dollar cost the docs attribute to a + * single request. + * + * Source: https://opencode.ai/docs/go/#usage-limits + */ +export const OPENCODE_GO_BUDGET_5H_USD = 12; + +/** + * Fetches the OpenCode Go model list from the public docs source. + * OpenCode Go has no /v1/models endpoint, so the canonical list lives + * in the markdown docs file the website renders from. We parse the + * Endpoints table (for model IDs + API format) and the Usage Limits + * table (for per-request cost) and cache the merged result in memory. + * + * ADR 0003: the doc lives on raw.githubusercontent.com, so it is fetched only + * for an instance that has an OpenCode Go provider registered, and never with + * `THINKER_CATALOG_SYNC=false`. Every caller of `list()` / `refresh()` / + * `resolve*()` other than the boot warmup is already scoped to an OpenCode Go + * provider (its discovery, its proxied requests, its models in the picker), so + * the boot warmup is the only path that has to check the database itself. + * Registering a provider runs its discovery, which loads the doc lazily. + */ +@Injectable() +export class OpencodeGoCatalogService implements OnModuleInit { + private readonly logger = new Logger(OpencodeGoCatalogService.name); + private cache: { entries: OpencodeGoCatalogEntry[]; expiresAt: number } | null = null; + private lastGood: OpencodeGoCatalogEntry[] | null = null; + private costByModelId = new Map(); + private formatByModelId = new Map(); + /** + * In-flight fetch promise — concurrent callers (e.g. the onModuleInit + * warmup plus the first proxy request) share the same fetch instead of + * each issuing their own. Cleared once the fetch settles so the next call + * after cache expiry re-fetches normally. + */ + private inflight: Promise | null = null; + + constructor( + @Optional() + @Inject(getRepositoryToken(TenantProvider)) + private readonly providerRepo: Repository | null = null, + ) {} + + /** + * Warm the catalog in the background at boot so the per-request cost index + * is populated before the proxy hot path runs. Discovery typically warms it + * on its own, but a cold process restart can record OpenCode Go calls + * against an empty index — the recorder treats a missing entry as $0, + * which is the original bug. Fire-and-forget; failures fall through to the + * existing fetch-on-demand path in `list()`. + * + * Skipped unless an OpenCode Go provider is registered (ADR 0003), so a + * default instance never reaches raw.githubusercontent.com at boot. + */ + onModuleInit(): void { + void this.warmIfProviderRegistered().catch(() => { + // `list()` already logs failures and arms the error-backoff window. + }); + } + + private async warmIfProviderRegistered(): Promise { + if (!isCatalogSyncEnabled()) return; + if (!(await this.hasOpencodeGoProvider())) return; + await this.list(); + } + + /** + * Whether any tenant has an OpenCode Go provider registered, active or not. + * Without a repository (unit tests that build the service by hand) or when + * the lookup fails, answers false: a skipped warmup only delays the doc to + * its first real use, while a wrong true is an outbound call nobody needed. + */ + private async hasOpencodeGoProvider(): Promise { + if (!this.providerRepo) return false; + try { + const count = await this.providerRepo + .createQueryBuilder('p') + .where('LOWER(p.provider) IN (:...keys)', { keys: OPENCODE_GO_PROVIDER_KEYS }) + .getCount(); + return count > 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Could not check for OpenCode Go providers, skipping warmup: ${message}`); + return false; + } + } + + /** + * The cached catalog, fetching it when the cache is cold or expired. Empty, + * without a fetch, when `THINKER_CATALOG_SYNC=false`: callers then price + * OpenCode Go requests at $0 and pick the API format from the model family. + */ + async list(): Promise { + if (!isCatalogSyncEnabled()) return []; + const now = Date.now(); + if (this.cache && this.cache.expiresAt > now) { + return this.cache.entries; + } + if (this.inflight) { + // A concurrent caller (typically the onModuleInit warmup) is already + // fetching. Share its result so we don't issue a duplicate request. + return this.inflight; + } + return this.startFetch(now); + } + + /** + * Force a live catalog fetch even when the one-hour cache is warm. Manual + * model refreshes use this so users can pick up newly published OpenCode Go + * models without restarting the backend. + */ + async refresh(): Promise { + if (!isCatalogSyncEnabled()) { + this.logger.log(`${describeCatalogSyncSetting()}: OpenCode Go docs catalog not fetched`); + return []; + } + return this.startFetch(Date.now()); + } + + private startFetch(now: number): Promise { + const request = this.fetchAndCache(now); + const tracked = request.finally(() => { + if (this.inflight === tracked) { + this.inflight = null; + } + }); + this.inflight = tracked; + return tracked; + } + + private async fetchAndCache(now: number): Promise { + try { + const response = await outboundFetch(CATALOG_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + this.logger.warn(`OpenCode Go catalog fetch returned ${response.status}`); + return this.cacheFallback(now); + } + const mdx = await response.text(); + const entries = this.parse(mdx); + if (entries.length === 0) { + this.logger.warn('OpenCode Go catalog parsed zero entries — docs format may have changed'); + return this.cacheFallback(now); + } + this.cache = { entries, expiresAt: now + CACHE_TTL_MS }; + this.lastGood = entries; + this.rebuildIndexes(entries); + this.logger.log(`OpenCode Go catalog loaded: ${entries.length} models`); + return entries; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`OpenCode Go catalog fetch failed: ${message}`); + return this.cacheFallback(now); + } + } + + /** + * Sync lookup for the per-request cost of an OpenCode Go model. Returns + * `null` when the catalog has not been loaded yet, when the docs did not + * publish a limit for the model, or when the model is unknown. Accepts + * either a bare model ID (`glm-5.1`) or the prefixed form + * (`opencode-go/glm-5.1`) — callers don't need to strip the prefix. + */ + getCostPerRequest(modelId: string | null | undefined): number | null { + const bare = bareOpencodeGoModelId(modelId); + if (!bare) return null; + return this.costByModelId.get(bare) ?? null; + } + + /** + * Sync lookup for the API format an OpenCode Go model expects. Returns + * `null` before the docs catalog has loaded or when the model is unknown. + */ + getFormat(modelId: string | null | undefined): OpencodeGoCatalogEntry['format'] | null { + const bare = bareOpencodeGoModelId(modelId); + if (!bare) return null; + return this.formatByModelId.get(bare) ?? null; + } + + /** + * Same as `getCostPerRequest`, but if the in-memory index is empty (cold + * process — `onModuleInit` warmup hasn't resolved yet) waits on the + * already-running `list()` call before reading. `list()` caches on both + * success and failure, so this is "fetch at most once per cache window", + * not per call. Returns `null` if the docs still don't publish a limit + * for the model after warming. + */ + async resolveCostPerRequest(modelId: string | null | undefined): Promise { + if (!modelId) return null; + if (this.costByModelId.size === 0) { + await this.list(); + } + return this.getCostPerRequest(modelId); + } + + /** + * Async format lookup for cold processes. Shares the same cached docs fetch + * as list()/resolveCostPerRequest(), so concurrent first calls coalesce. + */ + async resolveFormat( + modelId: string | null | undefined, + ): Promise { + if (!modelId) return null; + if (this.formatByModelId.size === 0) { + await this.list(); + } + return this.getFormat(modelId); + } + + /** + * Set a short error-backoff cache so repeated calls during an outage do not + * hammer the network, then return the last-known-good list (or []). + */ + private cacheFallback(now: number): OpencodeGoCatalogEntry[] { + const entries = this.lastGood ?? []; + this.cache = { entries, expiresAt: now + ERROR_BACKOFF_MS }; + return entries; + } + + /** Parse the Endpoints and Usage Limits tables out of the go.mdx source. */ + parse(mdx: string): OpencodeGoCatalogEntry[] { + const costByName = this.parseLimits(mdx); + const rowRe = + /\|\s*([A-Za-z][^|]*?)\s*\|\s*([a-z][a-z0-9.-]*)\s*\|\s*`?https:\/\/opencode\.ai\/zen\/go\/v1\/(chat\/completions|messages|responses)`?\s*\|/g; + const entries: OpencodeGoCatalogEntry[] = []; + const seen = new Set(); + let match: RegExpExecArray | null; + while ((match = rowRe.exec(mdx)) !== null) { + const [, rawName, modelId, endpointSuffix] = match; + const displayName = rawName.trim(); + // The header row never matches: "Model ID" starts uppercase, failing the + // lowercase-anchored modelId group. Dash separator rows likewise start + // with '-', not [A-Za-z]. So any row reaching this point is a data row. + if (seen.has(modelId)) continue; + seen.add(modelId); + entries.push({ + id: modelId, + displayName, + format: + endpointSuffix === 'messages' + ? 'anthropic' + : endpointSuffix === 'responses' + ? 'responses' + : 'openai', + costPerRequestUsd: costByName.get(normalizeDisplayName(displayName)) ?? null, + }); + } + return entries; + } + + /** + * Parse the Usage Limits table. Maps normalized display name → USD per + * request, derived from the published 5-hour request count. The Endpoints + * table and the token-price table are excluded because their first column + * after the name is a model id or a dollar amount, not a bare count, and a + * cost is only ever read back for a name the Endpoints table also lists. + * + * Parsed row by row rather than with one table-wide regex because the docs + * dress these cells up: a promotional row strikes the superseded count + * through and bolds the new one (`~~6,500~~
**26,000**`) and hangs a + * `` note off the model name. A regex anchored on a bare number + * silently skipped those rows, so the model showed as quota-less. + */ + private parseLimits(mdx: string): Map { + const out = new Map(); + for (const line of mdx.split('\n')) { + const cells = splitTableRow(line); + if (cells === null || cells.length < 4) continue; + const [rawName, ...rest] = cells; + if (!/^[A-Za-z]/.test(rawName)) continue; + // Only the per-5-hour count is consumed. Gating on the per-week and + // per-month columns too would drop the whole row the day the docs dress + // one of them up, which is the failure this parser exists to prevent. + const requestsPer5h = parseRequestCount(rest[0]); + if (requestsPer5h === null) continue; + const key = normalizeDisplayName(rawName); + // First occurrence wins, mirroring the Endpoints table's dedup behaviour. + if (out.has(key)) continue; + out.set(key, OPENCODE_GO_BUDGET_5H_USD / requestsPer5h); + } + return out; + } + + private rebuildIndexes(entries: OpencodeGoCatalogEntry[]): void { + const nextCosts = new Map(); + const nextFormats = new Map(); + for (const entry of entries) { + nextFormats.set(entry.id, entry.format); + if (entry.costPerRequestUsd != null) { + nextCosts.set(entry.id, entry.costPerRequestUsd); + } + } + this.costByModelId = nextCosts; + this.formatByModelId = nextFormats; + } + + /** Test hook: clear in-memory state. */ + resetCache(): void { + this.cache = null; + this.lastGood = null; + this.costByModelId = new Map(); + this.formatByModelId = new Map(); + this.inflight = null; + } +} + +function bareOpencodeGoModelId(modelId: string | null | undefined): string | null { + if (!modelId) return null; + return modelId.startsWith('opencode-go/') ? modelId.slice('opencode-go/'.length) : modelId; +} + +/** + * Normalize a docs display name for cross-table matching. The Endpoints + * table lists "MiMo-V2.5" while the Usage Limits table lists + * "MiMo-V2.5 (≤ 256K)". Lowercase, strip any parenthesized suffix, and + * collapse whitespace so the two forms collide on the same key. + */ +function normalizeDisplayName(name: string): string { + return ( + name + // A name cell can carry an inline note ("DeepSeek V4.1 Flash
4x · Ends Sep 20"). Names never contain '<', so cutting + // there leaves the name the Endpoints table publishes. + .split('<')[0] + .replace(/\s*\([^)]*\)\s*/g, ' ') + .trim() + .replace(/\s+/g, ' ') + .toLowerCase() + ); +} + +/** + * Split one markdown table line into trimmed cells, or null when the line is + * not a table row. + */ +function splitTableRow(line: string): string[] | null { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) return null; + const body = trimmed.endsWith('|') ? trimmed.slice(1, -1) : trimmed.slice(1); + return body.split('|').map((cell) => cell.trim()); +} + +/** + * Read a request count out of one cell, or null when the cell holds anything + * else ("Unlimited", a dollar amount, a model id). Struck-through values are + * superseded by the promoted one next to them, so they are dropped before the + * number is read; the surviving markup (`
`, `**`) is stripped. + */ +function parseRequestCount(cell: string): number | null { + const effective = cell + .replace(/~~[^~]*~~/g, ' ') + .replace(/<[^>]*>/g, ' ') + .replace(/\*/g, '') + .trim(); + if (!/^[0-9][0-9,]*$/.test(effective)) return null; + const value = Number(effective.replace(/,/g, '')); + return Number.isFinite(value) && value > 0 ? value : null; +} diff --git a/packages/frontend/public/brand/favicon.svg b/packages/frontend/public/brand/favicon.svg new file mode 100644 index 00000000..0ffb37a4 --- /dev/null +++ b/packages/frontend/public/brand/favicon.svg @@ -0,0 +1,5 @@ +Thinker + + + + diff --git a/packages/frontend/public/brand/thinker-logo-dark.svg b/packages/frontend/public/brand/thinker-logo-dark.svg new file mode 100644 index 00000000..5bcaf497 --- /dev/null +++ b/packages/frontend/public/brand/thinker-logo-dark.svg @@ -0,0 +1,5 @@ +Thinker + + + + diff --git a/packages/frontend/public/brand/thinker-logo.svg b/packages/frontend/public/brand/thinker-logo.svg new file mode 100644 index 00000000..d4ce9387 --- /dev/null +++ b/packages/frontend/public/brand/thinker-logo.svg @@ -0,0 +1,5 @@ +Thinker + + + + diff --git a/packages/frontend/public/brand/thinker-mark-dark.svg b/packages/frontend/public/brand/thinker-mark-dark.svg new file mode 100644 index 00000000..fc8b95dc --- /dev/null +++ b/packages/frontend/public/brand/thinker-mark-dark.svg @@ -0,0 +1,4 @@ +Thinker + + + diff --git a/packages/frontend/public/brand/thinker-mark.svg b/packages/frontend/public/brand/thinker-mark.svg new file mode 100644 index 00000000..1e20412d --- /dev/null +++ b/packages/frontend/public/brand/thinker-mark.svg @@ -0,0 +1,4 @@ +Thinker + + + diff --git a/upstream/STATE.md b/upstream/STATE.md index 724dd9ac..9c60b488 100644 --- a/upstream/STATE.md +++ b/upstream/STATE.md @@ -21,3 +21,4 @@ Os cortes são timestamps ISO-8601 em UTC. A seleção usa `updated_at >= corte` | 2026-10-10 | — | — | — | Baseline do snapshot inicial | — | Ponto de partida | | 2026-10-10 (2ª) | 0 (`main` == baseline) | 0 (último: `manifest@6.28.6`) | 0 | Nenhum; discussions mais recentes (17/09/2026) anteriores ao baseline | WebFetch | Do PR #4: `main` == baseline, verificação via WebFetch; API do upstream bloqueada. Esta execução **não avançou o corte**. | | 2026-10-12 | 0 (`main` == baseline `45627a2`) | 0 (último: `manifest@6.28.6`) | 16 (#120 e #122-#136: 10 adotar, 6 adaptar) | Dependabot (#3010-#3016, #3043), telemetria (#2572), cadastro duplicado (#2621, Thinker não tem cadastro aberto), docs de deploy ZopDay (#3044), skeleton loaders (#2281, #2269, #2270), Claude Code pede login (#3052, sem causa confirmada), usage.cost (#2605), refatorações menores | Commits: git local + `gh api compare`; Releases: `gh api`; Issues e PRs: `gh api` (REST); Discussions: WebFetch (GraphQL bloqueado; sem `updated_at`) | Corte de Discussions **não avançado** (a página não mostra data de atualização; a mais recente listada é de 17/09/2026). Backlog triado. Os itens abertos são PRs não mesclados; o código pode mudar. | +| 2026-11-09 | 11 (`b0c81b0`..`e48ea50`) | 2 (6.29.0, 6.29.1) | 3 (#495 e #496: adotar; #497: adaptar). Comentário na #302 (PR #3122 mesclado) | Version Packages (#3104, #3117), remoção de recordings no Cloud (#3116), modelparams 0.0.72 (#3109, Thinker já em ^0.0.72), Dependabot (#3112, #3100, #3011, #3015), #3113/#3114/#3115 (GPT-6.1 Sol e Fable, abertos; citados na #497) | Commits: git local; Releases: `gh api`; Issues e PRs: `gh api` (REST); Discussions: GraphQL não tentado nesta sessão | Corte de Discussions **não avançado** (mantido em 2026-10-09T14:32:18Z). A branch de revisão não tinha histórico comum com `main`; merge feito com `--allow-unrelated-histories -X theirs`. |