Skip to content

Commit 2493d97

Browse files
feat: add OpenCode Go as a subscription provider (#1520)
* feat: add OpenCode Go as a subscription provider Add OpenCode Go to the routing layer as a token-based subscription provider. The model list is fetched dynamically from the public OpenCode Go docs source and cached in memory for one hour, with a last-known-good fallback on fetch failures — no hardcoded model list in the codebase. The proxy routes GLM, Kimi, and MiMo models through the OpenAI-compatible `/v1/chat/completions` endpoint and both MiniMax models through the Anthropic-compatible `/v1/messages` endpoint, both served from the same `https://opencode.ai/zen/go` base URL. The Anthropic endpoint authenticates via `x-api-key` (not `Authorization: Bearer`), matching the native Anthropic wire protocol that OpenCode Go's upstream expects. In the UI, OpenCode Go lives in the subscription tab with a custom sign-in button that opens opencode.ai/auth in a new tab; users copy their API key from the dashboard and paste it into the token field. The existing token paste flow on `ProviderDetailView` is now data-driven via three new optional fields on `ProviderDef` (`subscriptionSignInUrl`, `subscriptionSignInLabel`, `subscriptionSignInHint`). * fix: use real OpenCode brand mark for opencode-go icon Replace the placeholder square/block approximation with the actual OpenCode logo (fetched from https://opencode.ai/favicon.svg). The glyph has a dark background, a white rounded outer frame, and a gray inner fill — it's the same mark the OpenCode website and docs use. Plugin public assets regenerated so the bundled dashboard ships the new icon alongside the new Z.ai subscription provider from main. * test: raise patch coverage on opencode-go catalog and icon - Add ProviderIcon smoke test for 'opencode-go' so the new case arm is covered (frontend coverage dropped from 98.58 to 98.56 on the initial push because the icon was never rendered in any test). - Drop the dead header-row skip in OpencodeGoCatalogService.parse; the regex already filters header rows via its lowercase-anchored model-id group. Comment in place explaining why. - Add a non-Error throw test to exercise the String(err) fallback in OpencodeGoCatalogService.list. * fix: address review feedback Cubic flagged (P2) that after the success TTL expired, a failing fetch would return the last-known-good list but never refresh cache.expiresAt, so every subsequent call would hit the network again — turning a sustained outage into a per-call retry storm. Introduce a short ERROR_BACKOFF_MS (5 minutes) and funnel all failure paths (non-ok response, zero-row parse, thrown exception) through a single cacheFallback helper that writes a short-lived cache entry. Successful fetches still use the full 1 hour TTL. * fix: polish OpenCode Go provider detail view - Use official brand logos with light/dark mode variants - Make sign-in button small and dark (btn--primary btn--sm) - Add beta badge next to provider name - Simplify sign-in hint text --------- Co-authored-by: Sébastien Conejo <sebastien@buddyweb.fr>
1 parent 9c23880 commit 2493d97

34 files changed

+987
-61
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
feat: add OpenCode Go as a subscription provider with dynamic model discovery
6+
7+
OpenCode Go is a low-cost subscription that exposes GLM, Kimi, MiMo, and MiniMax
8+
models through a unified API. Users sign in at opencode.ai/auth, copy their API
9+
key, and paste it into the OpenCode Go detail view in the routing UI. The backend
10+
routes GLM/Kimi/MiMo models through the OpenAI-compatible endpoint and MiniMax
11+
models through the Anthropic-compatible endpoint — both served from the same
12+
`https://opencode.ai/zen/go` base URL. The Anthropic endpoint authenticates via
13+
`x-api-key` (not Bearer), matching the native Anthropic wire protocol.
14+
15+
The model list is fetched dynamically from the public OpenCode Go docs source
16+
and cached in memory for one hour, with a last-known-good fallback on fetch
17+
failures. No hardcoded model list in the codebase.

packages/backend/src/common/constants/providers.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
} from './providers';
1010

1111
describe('PROVIDER_REGISTRY', () => {
12-
it('should contain exactly 14 provider entries', () => {
13-
expect(PROVIDER_REGISTRY).toHaveLength(14);
12+
it('should contain exactly 15 provider entries', () => {
13+
expect(PROVIDER_REGISTRY).toHaveLength(15);
1414
});
1515

1616
it('ollama-cloud has localOnly=false and requiresApiKey=false', () => {
@@ -79,14 +79,24 @@ describe('PROVIDER_REGISTRY', () => {
7979
expect(copilot!.aliases).toEqual([]);
8080
expect(copilot!.openRouterPrefixes).toEqual([]);
8181
});
82+
83+
it('opencode-go is registered with opencodego alias and no OpenRouter prefix', () => {
84+
const og = PROVIDER_REGISTRY.find((p) => p.id === 'opencode-go');
85+
expect(og).toBeDefined();
86+
expect(og!.displayName).toBe('OpenCode Go');
87+
expect(og!.aliases).toEqual(['opencodego']);
88+
expect(og!.openRouterPrefixes).toEqual([]);
89+
expect(og!.requiresApiKey).toBe(true);
90+
expect(og!.localOnly).toBe(false);
91+
});
8292
});
8393

8494
describe('PROVIDER_BY_ID', () => {
85-
it('resolves all 14 provider IDs', () => {
95+
it('resolves all 15 provider IDs', () => {
8696
for (const entry of PROVIDER_REGISTRY) {
8797
expect(PROVIDER_BY_ID.get(entry.id)).toBe(entry);
8898
}
89-
expect(PROVIDER_BY_ID.size).toBe(14);
99+
expect(PROVIDER_BY_ID.size).toBe(15);
90100
});
91101

92102
it('returns undefined for an unknown ID', () => {

packages/backend/src/common/constants/providers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
125125
requiresApiKey: false,
126126
localOnly: false,
127127
},
128+
{
129+
id: 'opencode-go',
130+
displayName: 'OpenCode Go',
131+
aliases: ['opencodego'],
132+
openRouterPrefixes: [],
133+
requiresApiKey: true,
134+
localOnly: false,
135+
},
128136
{
129137
id: 'openrouter',
130138
displayName: 'OpenRouter',

packages/backend/src/model-discovery/model-discovery.module.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ import { CustomProvider } from '../entities/custom-provider.entity';
55
import { ModelPricesModule } from '../model-prices/model-prices.module';
66
import { ProviderModelFetcherService } from './provider-model-fetcher.service';
77
import { ModelDiscoveryService } from './model-discovery.service';
8+
import { OpencodeGoCatalogService } from './opencode-go-catalog.service';
89
import { CopilotTokenService } from '../routing/proxy/copilot-token.service';
910

1011
@Module({
1112
imports: [TypeOrmModule.forFeature([UserProvider, CustomProvider]), ModelPricesModule],
12-
providers: [ProviderModelFetcherService, ModelDiscoveryService, CopilotTokenService],
13-
exports: [ModelDiscoveryService, ProviderModelFetcherService],
13+
providers: [
14+
ProviderModelFetcherService,
15+
ModelDiscoveryService,
16+
OpencodeGoCatalogService,
17+
CopilotTokenService,
18+
],
19+
exports: [ModelDiscoveryService, ProviderModelFetcherService, OpencodeGoCatalogService],
1420
})
1521
export class ModelDiscoveryModule {}

packages/backend/src/model-discovery/model-fallback.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,13 @@ describe('buildSubscriptionFallbackModels', () => {
279279

280280
expect(result.length).toBeGreaterThanOrEqual(1);
281281
});
282+
283+
it('returns [] for opencode-go because its catalog is fetched dynamically', () => {
284+
// OpenCode Go has no hardcoded knownModels. The fallback path must stay empty
285+
// so we do not fabricate stale entries when the live catalog is unreachable.
286+
const result = buildSubscriptionFallbackModels(makePricingSync(new Map()), 'opencode-go');
287+
expect(result).toEqual([]);
288+
});
282289
});
283290

284291
describe('supplementWithKnownModels', () => {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { OpencodeGoCatalogService } from './opencode-go-catalog.service';
2+
3+
const BT = String.fromCharCode(96);
4+
const OAI = BT + 'https://opencode.ai/zen/go/v1/chat/completions' + BT;
5+
const ANT = BT + 'https://opencode.ai/zen/go/v1/messages' + BT;
6+
const OAI_SDK = BT + '@ai-sdk/openai-compatible' + BT;
7+
const ANT_SDK = BT + '@ai-sdk/anthropic' + BT;
8+
9+
const SAMPLE_MDX = [
10+
'---',
11+
'title: Go',
12+
'description: Low cost subscription for open coding models.',
13+
'---',
14+
'',
15+
'## Endpoints',
16+
'',
17+
'| Model | Model ID | Endpoint | AI SDK Package |',
18+
'| ------------ | ------------ | ------------------------------------------------ | --------------------------- |',
19+
`| GLM-5.1 | glm-5.1 | ${OAI} | ${OAI_SDK} |`,
20+
`| GLM-5 | glm-5 | ${OAI} | ${OAI_SDK} |`,
21+
`| Kimi K2.5 | kimi-k2.5 | ${OAI} | ${OAI_SDK} |`,
22+
`| MiMo-V2-Pro | mimo-v2-pro | ${OAI} | ${OAI_SDK} |`,
23+
`| MiMo-V2-Omni | mimo-v2-omni | ${OAI} | ${OAI_SDK} |`,
24+
`| MiniMax M2.7 | minimax-m2.7 | ${ANT} | ${ANT_SDK} |`,
25+
`| MiniMax M2.5 | minimax-m2.5 | ${ANT} | ${ANT_SDK} |`,
26+
'',
27+
].join('\n');
28+
29+
describe('OpencodeGoCatalogService', () => {
30+
let service: OpencodeGoCatalogService;
31+
let fetchSpy: jest.SpyInstance;
32+
33+
beforeEach(() => {
34+
service = new OpencodeGoCatalogService();
35+
fetchSpy = jest.spyOn(global, 'fetch');
36+
});
37+
38+
afterEach(() => {
39+
fetchSpy.mockRestore();
40+
});
41+
42+
describe('parse', () => {
43+
it('extracts every model in the endpoints table', () => {
44+
const entries = service.parse(SAMPLE_MDX);
45+
expect(entries.map((e) => e.id)).toEqual([
46+
'glm-5.1',
47+
'glm-5',
48+
'kimi-k2.5',
49+
'mimo-v2-pro',
50+
'mimo-v2-omni',
51+
'minimax-m2.7',
52+
'minimax-m2.5',
53+
]);
54+
});
55+
56+
it('keeps the docs display name verbatim', () => {
57+
const entries = service.parse(SAMPLE_MDX);
58+
const labels = Object.fromEntries(entries.map((e) => [e.id, e.displayName]));
59+
expect(labels['glm-5.1']).toBe('GLM-5.1');
60+
expect(labels['kimi-k2.5']).toBe('Kimi K2.5');
61+
expect(labels['mimo-v2-omni']).toBe('MiMo-V2-Omni');
62+
expect(labels['minimax-m2.7']).toBe('MiniMax M2.7');
63+
});
64+
65+
it('tags MiniMax rows as anthropic and everything else as openai', () => {
66+
const entries = service.parse(SAMPLE_MDX);
67+
const byId = Object.fromEntries(entries.map((e) => [e.id, e.format]));
68+
expect(byId['glm-5.1']).toBe('openai');
69+
expect(byId['kimi-k2.5']).toBe('openai');
70+
expect(byId['mimo-v2-pro']).toBe('openai');
71+
expect(byId['minimax-m2.5']).toBe('anthropic');
72+
expect(byId['minimax-m2.7']).toBe('anthropic');
73+
});
74+
75+
it('never matches the header row (uppercase model ID column fails regex)', () => {
76+
// The regex anchors the model-id group on [a-z], so "Model ID" in the
77+
// header row column does not match. No explicit skip needed.
78+
const entries = service.parse(SAMPLE_MDX);
79+
expect(entries.find((e) => e.displayName === 'Model')).toBeUndefined();
80+
expect(entries.find((e) => e.id === 'model id')).toBeUndefined();
81+
});
82+
83+
it('returns an empty array when the table is missing', () => {
84+
expect(service.parse('# Go\n\nNo table here.')).toEqual([]);
85+
});
86+
87+
it('deduplicates if a model appears twice', () => {
88+
const doubled = SAMPLE_MDX + '\n' + SAMPLE_MDX;
89+
const entries = service.parse(doubled);
90+
const ids = entries.map((e) => e.id);
91+
expect(new Set(ids).size).toBe(ids.length);
92+
});
93+
});
94+
95+
describe('list', () => {
96+
it('fetches, parses, and caches the catalog', async () => {
97+
fetchSpy.mockResolvedValue({
98+
ok: true,
99+
status: 200,
100+
text: async () => SAMPLE_MDX,
101+
} as Response);
102+
103+
const first = await service.list();
104+
expect(first).toHaveLength(7);
105+
expect(fetchSpy).toHaveBeenCalledTimes(1);
106+
107+
const second = await service.list();
108+
expect(second).toBe(first);
109+
expect(fetchSpy).toHaveBeenCalledTimes(1);
110+
});
111+
112+
it('returns the last good result when a later fetch fails', async () => {
113+
fetchSpy.mockResolvedValueOnce({
114+
ok: true,
115+
status: 200,
116+
text: async () => SAMPLE_MDX,
117+
} as Response);
118+
const good = await service.list();
119+
expect(good).toHaveLength(7);
120+
121+
// Force the success cache to look expired, but keep lastGood populated.
122+
(service as unknown as { cache: unknown }).cache = null;
123+
124+
fetchSpy.mockResolvedValueOnce({ ok: false, status: 500 } as Response);
125+
const afterFailure = await service.list();
126+
expect(afterFailure).toEqual(good);
127+
});
128+
129+
it('backs off after a failure so repeated calls do not hammer the network', async () => {
130+
// First fetch fails with nothing cached → returns [] and arms the
131+
// error-backoff window.
132+
fetchSpy.mockResolvedValueOnce({ ok: false, status: 503 } as Response);
133+
const first = await service.list();
134+
expect(first).toEqual([]);
135+
expect(fetchSpy).toHaveBeenCalledTimes(1);
136+
137+
// Second call within the backoff window reuses the cached fallback and
138+
// must NOT reach the network.
139+
const second = await service.list();
140+
expect(second).toEqual([]);
141+
expect(fetchSpy).toHaveBeenCalledTimes(1);
142+
});
143+
144+
it('returns [] when there is no prior cache and the fetch 404s', async () => {
145+
fetchSpy.mockResolvedValue({ ok: false, status: 404 } as Response);
146+
const result = await service.list();
147+
expect(result).toEqual([]);
148+
});
149+
150+
it('returns [] when the fetch throws an Error and nothing is cached', async () => {
151+
fetchSpy.mockRejectedValue(new Error('network down'));
152+
const result = await service.list();
153+
expect(result).toEqual([]);
154+
});
155+
156+
it('returns [] when the fetch throws a non-Error value', async () => {
157+
// Exercises the String(err) fallback when something non-Error is thrown.
158+
fetchSpy.mockRejectedValue('raw string failure');
159+
const result = await service.list();
160+
expect(result).toEqual([]);
161+
});
162+
163+
it('returns [] (not stale empty) when the docs parse to zero rows', async () => {
164+
fetchSpy.mockResolvedValue({
165+
ok: true,
166+
status: 200,
167+
text: async () => '# Nothing useful here',
168+
} as Response);
169+
const result = await service.list();
170+
expect(result).toEqual([]);
171+
});
172+
});
173+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
3+
export interface OpencodeGoCatalogEntry {
4+
/** Bare model ID as listed in the docs (e.g. "glm-5.1"). */
5+
id: string;
6+
/** Human-readable display name from the docs (e.g. "GLM-5.1"). */
7+
displayName: string;
8+
/** Which upstream API format the model expects. */
9+
format: 'openai' | 'anthropic';
10+
}
11+
12+
const CATALOG_URL =
13+
'https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/go.mdx';
14+
const CACHE_TTL_MS = 60 * 60 * 1000;
15+
// After a fetch failure we reuse the last-known-good list for a shorter window
16+
// so a sustained outage does not turn into a per-call retry storm.
17+
const ERROR_BACKOFF_MS = 5 * 60 * 1000;
18+
const FETCH_TIMEOUT_MS = 10_000;
19+
20+
/**
21+
* Fetches the OpenCode Go model list from the public docs source.
22+
* OpenCode Go has no /v1/models endpoint, so the canonical list lives
23+
* in the markdown docs file the website renders from. We parse the
24+
* Endpoints table and cache the result in memory.
25+
*/
26+
@Injectable()
27+
export class OpencodeGoCatalogService {
28+
private readonly logger = new Logger(OpencodeGoCatalogService.name);
29+
private cache: { entries: OpencodeGoCatalogEntry[]; expiresAt: number } | null = null;
30+
private lastGood: OpencodeGoCatalogEntry[] | null = null;
31+
32+
async list(): Promise<OpencodeGoCatalogEntry[]> {
33+
const now = Date.now();
34+
if (this.cache && this.cache.expiresAt > now) {
35+
return this.cache.entries;
36+
}
37+
38+
try {
39+
const response = await fetch(CATALOG_URL, {
40+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
41+
});
42+
if (!response.ok) {
43+
this.logger.warn(`OpenCode Go catalog fetch returned ${response.status}`);
44+
return this.cacheFallback(now);
45+
}
46+
const mdx = await response.text();
47+
const entries = this.parse(mdx);
48+
if (entries.length === 0) {
49+
this.logger.warn('OpenCode Go catalog parsed zero entries — docs format may have changed');
50+
return this.cacheFallback(now);
51+
}
52+
this.cache = { entries, expiresAt: now + CACHE_TTL_MS };
53+
this.lastGood = entries;
54+
this.logger.log(`OpenCode Go catalog loaded: ${entries.length} models`);
55+
return entries;
56+
} catch (err) {
57+
const message = err instanceof Error ? err.message : String(err);
58+
this.logger.warn(`OpenCode Go catalog fetch failed: ${message}`);
59+
return this.cacheFallback(now);
60+
}
61+
}
62+
63+
/**
64+
* Set a short error-backoff cache so repeated calls during an outage do not
65+
* hammer the network, then return the last-known-good list (or []).
66+
*/
67+
private cacheFallback(now: number): OpencodeGoCatalogEntry[] {
68+
const entries = this.lastGood ?? [];
69+
this.cache = { entries, expiresAt: now + ERROR_BACKOFF_MS };
70+
return entries;
71+
}
72+
73+
/** Parse the Endpoints markdown table out of the go.mdx source. */
74+
parse(mdx: string): OpencodeGoCatalogEntry[] {
75+
const rowRe =
76+
/\|\s*([A-Za-z][^|]*?)\s*\|\s*([a-z][a-z0-9.-]*)\s*\|\s*`?https:\/\/opencode\.ai\/zen\/go\/v1\/(chat\/completions|messages)`?\s*\|/g;
77+
const entries: OpencodeGoCatalogEntry[] = [];
78+
const seen = new Set<string>();
79+
let match: RegExpExecArray | null;
80+
while ((match = rowRe.exec(mdx)) !== null) {
81+
const [, rawName, modelId, endpointSuffix] = match;
82+
const displayName = rawName.trim();
83+
// The header row never matches: "Model ID" starts uppercase, failing the
84+
// lowercase-anchored modelId group. Dash separator rows likewise start
85+
// with '-', not [A-Za-z]. So any row reaching this point is a data row.
86+
if (seen.has(modelId)) continue;
87+
seen.add(modelId);
88+
entries.push({
89+
id: modelId,
90+
displayName,
91+
format: endpointSuffix === 'messages' ? 'anthropic' : 'openai',
92+
});
93+
}
94+
return entries;
95+
}
96+
97+
/** Test hook: clear in-memory state. */
98+
resetCache(): void {
99+
this.cache = null;
100+
this.lastGood = null;
101+
}
102+
}

0 commit comments

Comments
 (0)