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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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`. |