@dan-ai-studio/dshopencodego 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/client.js +15 -2
- package/lib/index.js +203 -52
- package/lib/types/catalog/contract.js +7 -0
- package/lib/types/catalog/go-doc.d.ts +57 -0
- package/lib/types/catalog/go-doc.js +192 -0
- package/lib/types/catalog/index.d.ts +37 -7
- package/lib/types/catalog/index.js +50 -9
- package/lib/types/catalog/json-response.d.ts +6 -3
- package/lib/types/catalog/json-response.js +6 -5
- package/lib/types/catalog/protocol.d.ts +7 -2
- package/lib/types/catalog/reading.d.ts +5 -0
- package/lib/types/catalog/reading.js +3 -1
- package/lib/types/go-limits.d.ts +23 -11
- package/lib/types/go-limits.js +24 -12
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider's own Go documentation as a data source.
|
|
3
|
+
*
|
|
4
|
+
* No API exposes the per-model allowances: the gateway's `/models` answers ids,
|
|
5
|
+
* and `/usage` answers account-wide percentages. The numbers live on a docs page
|
|
6
|
+
* whose source is a markdown file in the provider's repository, so this module
|
|
7
|
+
* fetches that file and parses its tables — allowances (with the provider's own
|
|
8
|
+
* request estimates) and the endpoint table that names each model's protocol.
|
|
9
|
+
*
|
|
10
|
+
* The document is the primary source. The transcribed table in `go-limits.ts`
|
|
11
|
+
* stays behind it as a startup seed and a permanent fallback for the day the
|
|
12
|
+
* URL moves, and its age is surfaced wherever it is used.
|
|
13
|
+
*
|
|
14
|
+
* @module @dan-ai-studio/dshopencodego/catalog/go-doc
|
|
15
|
+
*/
|
|
16
|
+
import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
|
|
17
|
+
import { METADATA_FETCH_TIMEOUT_MS } from "./constants.js";
|
|
18
|
+
import { readBoundedText } from "./json-response.js";
|
|
19
|
+
/**
|
|
20
|
+
* Documentation sources, tried in order.
|
|
21
|
+
*
|
|
22
|
+
* The repository file is the source of truth; the jsDelivr entry caches the
|
|
23
|
+
* same path and is tried when GitHub itself is unreachable.
|
|
24
|
+
*/
|
|
25
|
+
export const GO_DOC_URLS = [
|
|
26
|
+
'https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/go.mdx',
|
|
27
|
+
'https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/packages/web/src/content/docs/go.mdx',
|
|
28
|
+
];
|
|
29
|
+
/** The document is about 30 KB; the cap leaves room without inviting a flood. */
|
|
30
|
+
export const GO_DOC_MAX_BYTES = 1024 * 1024;
|
|
31
|
+
/** The protocol each documented endpoint path names. */
|
|
32
|
+
const ENDPOINT_PROTOCOLS = {
|
|
33
|
+
'/chat/completions': 'openai-completions',
|
|
34
|
+
'/messages': 'anthropic-messages',
|
|
35
|
+
'/responses': 'openai-responses',
|
|
36
|
+
};
|
|
37
|
+
/** Strip markdown decoration (`**bold**`, `` `code` ``) from one cell. */
|
|
38
|
+
function cell(value) {
|
|
39
|
+
return (value ?? '').replace(/[*`]/g, '').trim();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A display name without its tier suffix, so the three tables join.
|
|
43
|
+
* `Qwen3.7 Plus (≤ 256K tokens)` and `DeepSeek V4 Pro (Peak)` both reduce to the
|
|
44
|
+
* name the endpoint table carries.
|
|
45
|
+
*/
|
|
46
|
+
function baseName(name) {
|
|
47
|
+
return name.replace(/\s*\(.*\)\s*$/, '').trim();
|
|
48
|
+
}
|
|
49
|
+
/** `$60` → 60; `Unlimited 限时` → `unlimited`; anything else → undefined. */
|
|
50
|
+
function parseAllowance(value) {
|
|
51
|
+
if (/^unlimited\b/i.test(value) || value.startsWith('无限制'))
|
|
52
|
+
return 'unlimited';
|
|
53
|
+
const match = value.match(/\$([\d,]+(?:\.\d+)?)/);
|
|
54
|
+
if (match?.[1] === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
const amount = Number(match[1].replace(/,/g, ''));
|
|
57
|
+
return Number.isFinite(amount) ? amount : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** `6,320` → 6320; `Unlimited` → `unlimited`; anything else → undefined. */
|
|
60
|
+
function parseCount(value) {
|
|
61
|
+
if (/^unlimited\b/i.test(value) || value.startsWith('无限制'))
|
|
62
|
+
return 'unlimited';
|
|
63
|
+
const amount = Number(value.replace(/,/g, ''));
|
|
64
|
+
return value.length > 0 && Number.isFinite(amount) ? amount : undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The protocol a documented endpoint names.
|
|
68
|
+
*
|
|
69
|
+
* Matched by path suffix: the table spells full URLs, and every supported API
|
|
70
|
+
* lives at a distinct tail (`/chat/completions`, `/messages`, `/responses`).
|
|
71
|
+
*/
|
|
72
|
+
function protocolOfEndpoint(endpoint) {
|
|
73
|
+
for (const [suffix, protocol] of Object.entries(ENDPOINT_PROTOCOLS)) {
|
|
74
|
+
if (endpoint.endsWith(suffix))
|
|
75
|
+
return protocol;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The data rows of the table whose header carries `required`.
|
|
81
|
+
*
|
|
82
|
+
* Data starts after the header and its separator, and ends at the first line
|
|
83
|
+
* that is not a table row, so three tables in one document stay apart.
|
|
84
|
+
* @param lines - the document, split into lines.
|
|
85
|
+
* @param required - a header cell that table alone carries.
|
|
86
|
+
* @returns one array of trimmed cells per data row.
|
|
87
|
+
*/
|
|
88
|
+
function tableRows(lines, required) {
|
|
89
|
+
const start = lines.findIndex(line => line.startsWith('|') && line.includes(required));
|
|
90
|
+
if (start < 0)
|
|
91
|
+
return [];
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (let index = start + 2; index < lines.length; index++) {
|
|
94
|
+
const line = lines[index] ?? '';
|
|
95
|
+
if (!line.startsWith('|'))
|
|
96
|
+
break;
|
|
97
|
+
rows.push(line.split('|').slice(1, -1).map(entry => entry.trim()));
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse the Go documentation into the two data sets this plugin consumes.
|
|
103
|
+
*
|
|
104
|
+
* Titles are the join key: the allowance tables name models the way the page
|
|
105
|
+
* presents them while the endpoint table carries the ids, so ids come from the
|
|
106
|
+
* endpoint table and the other two attach by normalised display name. A row
|
|
107
|
+
* that names no known model, or states no usable number, is skipped rather than
|
|
108
|
+
* guessed; a tiered model whose rows disagree is dropped entirely.
|
|
109
|
+
* @param markdown - the raw document text.
|
|
110
|
+
* @returns the parsed allowances and protocols; both may be empty when the
|
|
111
|
+
* document does not carry the expected tables.
|
|
112
|
+
*/
|
|
113
|
+
export function parseGoDocument(markdown) {
|
|
114
|
+
const lines = markdown.split('\n');
|
|
115
|
+
const ids = new Map();
|
|
116
|
+
const protocols = new Map();
|
|
117
|
+
for (const row of tableRows(lines, 'Model ID')) {
|
|
118
|
+
const name = cell(row[0]);
|
|
119
|
+
const id = cell(row[1]);
|
|
120
|
+
if (name.length === 0 || id.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
ids.set(name, id);
|
|
123
|
+
const protocol = protocolOfEndpoint(cell(row[2]));
|
|
124
|
+
if (protocol !== undefined)
|
|
125
|
+
protocols.set(id, protocol);
|
|
126
|
+
}
|
|
127
|
+
const quotas = new Map();
|
|
128
|
+
for (const row of tableRows(lines, 'Monthly limit')) {
|
|
129
|
+
const id = ids.get(baseName(cell(row[0])));
|
|
130
|
+
if (id === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
const monthlyUsd = parseAllowance(cell(row[5]));
|
|
133
|
+
if (monthlyUsd === undefined)
|
|
134
|
+
continue;
|
|
135
|
+
const previous = quotas.get(id);
|
|
136
|
+
// Tiered rows (token bands, peak/off-peak) repeat one allowance; the first
|
|
137
|
+
// row wins, and a disagreement drops the model instead of picking a band.
|
|
138
|
+
if (previous === undefined)
|
|
139
|
+
quotas.set(id, { monthlyUsd });
|
|
140
|
+
else if (previous.monthlyUsd !== monthlyUsd)
|
|
141
|
+
quotas.delete(id);
|
|
142
|
+
}
|
|
143
|
+
for (const row of tableRows(lines, 'requests per month')) {
|
|
144
|
+
const id = ids.get(baseName(cell(row[0])));
|
|
145
|
+
if (id === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const monthlyRequests = parseCount(cell(row[3]));
|
|
148
|
+
const quota = quotas.get(id);
|
|
149
|
+
if (monthlyRequests === undefined || quota === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
quotas.set(id, { ...quota, monthlyRequests });
|
|
152
|
+
}
|
|
153
|
+
return { quotas, protocols };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Fetch and parse the documentation, trying each source in turn.
|
|
157
|
+
*
|
|
158
|
+
* A source that answers without a usable allowance table counts as a failure,
|
|
159
|
+
* not as an empty document: that is what a moved or restructured page looks
|
|
160
|
+
* like, and the caller must keep its previous data rather than blank the page.
|
|
161
|
+
* @param signal - caller cancellation, if any.
|
|
162
|
+
* @returns the parsed document from the first source that carried one.
|
|
163
|
+
* @throws {LlmError} `DOCUMENT_UNAVAILABLE` when every source failed.
|
|
164
|
+
*/
|
|
165
|
+
export async function fetchGoDocument(signal) {
|
|
166
|
+
const failures = [];
|
|
167
|
+
for (const url of GO_DOC_URLS) {
|
|
168
|
+
try {
|
|
169
|
+
const timeout = AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS);
|
|
170
|
+
const response = await fetch(url, {
|
|
171
|
+
redirect: 'error',
|
|
172
|
+
headers: { ...attributionHeaders(), accept: 'text/plain', 'cache-control': 'no-cache' },
|
|
173
|
+
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
await response.body?.cancel().catch(() => { });
|
|
177
|
+
failures.push(`${url} answered HTTP ${response.status}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
|
|
181
|
+
if (document.quotas.size === 0) {
|
|
182
|
+
failures.push(`${url} carried no allowance table`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
return document;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new LlmError(`could not read the Go documentation (${failures.join('; ')})`, 'DOCUMENT_UNAVAILABLE');
|
|
192
|
+
}
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Membership comes from the gateway listing, capability from models.dev, and
|
|
5
5
|
* exact protocol/wire quirks from the installed pi-ai catalog, merged through
|
|
6
|
-
* the ladder in `./protocol.ts`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* the ladder in `./protocol.ts`. Per-model allowances, and a protocol for the
|
|
7
|
+
* models nothing else covers, come from the provider's own documentation (see
|
|
8
|
+
* `./go-doc.ts`). The snapshot is cached for the configured refresh interval,
|
|
9
|
+
* and an unknown model id forces one revalidation even inside that interval —
|
|
10
|
+
* that is what makes a model the gateway added this morning callable this
|
|
11
|
+
* afternoon without a plugin release.
|
|
10
12
|
*
|
|
11
13
|
* Failures degrade rather than empty: a listing outage keeps the last known
|
|
12
14
|
* models and marks the snapshot not-live, a metadata outage keeps the last
|
|
13
|
-
* document,
|
|
14
|
-
* model
|
|
15
|
+
* document, a documentation outage keeps the last parsed allowances (or leaves
|
|
16
|
+
* the page on its frozen seed), and a single unparseable model is reported by
|
|
17
|
+
* id while every other model keeps serving.
|
|
15
18
|
*
|
|
16
19
|
* @module @dan-ai-studio/dshopencodego/catalog
|
|
17
20
|
*/
|
|
18
21
|
import type { Provider } from '@earendil-works/pi-ai';
|
|
19
22
|
import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm';
|
|
23
|
+
import type { GoDoc } from './go-doc.ts';
|
|
20
24
|
import type { ModelDefaults, ModelFacts } from './metadata.ts';
|
|
25
|
+
import type { GoQuota } from '../go-limits.ts';
|
|
21
26
|
export { PROVIDER_ID, DISPLAY_NAME, DEFAULT_BASE_URL } from './constants.ts';
|
|
22
27
|
export { readModelIds, fetchModelIds } from './gateway.ts';
|
|
23
28
|
export { readOnlineMetadata, toPiModel, modelBaseURL } from './metadata.ts';
|
|
@@ -34,6 +39,12 @@ export interface CatalogSnapshot {
|
|
|
34
39
|
readonly provider: Provider;
|
|
35
40
|
/** Whether the gateway listing answered during this build. */
|
|
36
41
|
readonly live: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Allowances parsed from the provider's own documentation, once a fetch has
|
|
44
|
+
* succeeded in this process. Absent until then, and across restarts; the page
|
|
45
|
+
* falls back to the frozen seed in `go-limits.ts`.
|
|
46
|
+
*/
|
|
47
|
+
readonly documentQuotas?: ReadonlyMap<string, GoQuota>;
|
|
37
48
|
/** Retained for explicit discovery when the listing failed. */
|
|
38
49
|
readonly listingFailure?: unknown;
|
|
39
50
|
readonly fetchedAtMs: number;
|
|
@@ -63,6 +74,11 @@ export interface CatalogOptions {
|
|
|
63
74
|
/** Configured per-id protocol overrides. */
|
|
64
75
|
readonly overrides: Readonly<Record<string, string>>;
|
|
65
76
|
readonly observers?: CatalogObservers;
|
|
77
|
+
/**
|
|
78
|
+
* Read the provider documentation. Defaults to the real fetch; tests inject a
|
|
79
|
+
* fixed document so the suite stays offline.
|
|
80
|
+
*/
|
|
81
|
+
readonly readDocument?: () => Promise<GoDoc>;
|
|
66
82
|
}
|
|
67
83
|
/**
|
|
68
84
|
* One gateway's catalog. Requests reuse a cached snapshot; explicit discovery
|
|
@@ -75,6 +91,8 @@ export declare class OpencodeGoCatalog {
|
|
|
75
91
|
private metadataDocument;
|
|
76
92
|
private metadataETag;
|
|
77
93
|
private lastMetadata;
|
|
94
|
+
/** The last parsed documentation; retained across failed fetches. */
|
|
95
|
+
private document;
|
|
78
96
|
constructor(options: CatalogOptions);
|
|
79
97
|
/** The cached snapshot, refreshed when it is older than the configured TTL. */
|
|
80
98
|
snapshot(force?: boolean): Promise<CatalogSnapshot>;
|
|
@@ -89,10 +107,22 @@ export declare class OpencodeGoCatalog {
|
|
|
89
107
|
* the id but this build cannot configure it, naming the reason.
|
|
90
108
|
*/
|
|
91
109
|
forModel(id: string): Promise<CatalogSnapshot>;
|
|
92
|
-
/** Revalidate
|
|
110
|
+
/** Revalidate every source, reusing the metadata document when it is unchanged. */
|
|
93
111
|
private build;
|
|
94
112
|
/** Merge the listing with the metadata document and the installed entries. */
|
|
95
113
|
private assemble;
|
|
114
|
+
/** The document allowances as a snapshot field, absent until one fetch lands. */
|
|
115
|
+
private documentQuotas;
|
|
116
|
+
/**
|
|
117
|
+
* Prefer the documented protocol over anything below the installed catalog.
|
|
118
|
+
*
|
|
119
|
+
* The endpoint table is the provider's own statement, so it outranks both the
|
|
120
|
+
* family guess and models.dev's SDK hint — this turns an `inferred` badge into
|
|
121
|
+
* a known answer for ids the installed catalog misses. It never overrides a
|
|
122
|
+
* builtin entry or a configured override: those carry the wire quirks the
|
|
123
|
+
* adapter relies on.
|
|
124
|
+
*/
|
|
125
|
+
private withDocumentedProtocol;
|
|
96
126
|
/** Facts for an id no source describes, from the ladder and the route defaults. */
|
|
97
127
|
private inferredFacts;
|
|
98
128
|
/** Conditional metadata GET: an unchanged document keeps its parsed form. */
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Membership comes from the gateway listing, capability from models.dev, and
|
|
5
5
|
* exact protocol/wire quirks from the installed pi-ai catalog, merged through
|
|
6
|
-
* the ladder in `./protocol.ts`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* the ladder in `./protocol.ts`. Per-model allowances, and a protocol for the
|
|
7
|
+
* models nothing else covers, come from the provider's own documentation (see
|
|
8
|
+
* `./go-doc.ts`). The snapshot is cached for the configured refresh interval,
|
|
9
|
+
* and an unknown model id forces one revalidation even inside that interval —
|
|
10
|
+
* that is what makes a model the gateway added this morning callable this
|
|
11
|
+
* afternoon without a plugin release.
|
|
10
12
|
*
|
|
11
13
|
* Failures degrade rather than empty: a listing outage keeps the last known
|
|
12
14
|
* models and marks the snapshot not-live, a metadata outage keeps the last
|
|
13
|
-
* document,
|
|
14
|
-
* model
|
|
15
|
+
* document, a documentation outage keeps the last parsed allowances (or leaves
|
|
16
|
+
* the page on its frozen seed), and a single unparseable model is reported by
|
|
17
|
+
* id while every other model keeps serving.
|
|
15
18
|
*
|
|
16
19
|
* @module @dan-ai-studio/dshopencodego/catalog
|
|
17
20
|
*/
|
|
@@ -23,6 +26,7 @@ import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.l
|
|
|
23
26
|
import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
|
|
24
27
|
import { DISPLAY_NAME, MODEL_METADATA_MAX_BYTES, MODEL_METADATA_URL, PROVIDER_ID, METADATA_FETCH_TIMEOUT_MS, } from "./constants.js";
|
|
25
28
|
import { fetchModelIds } from "./gateway.js";
|
|
29
|
+
import { fetchGoDocument, GO_DOC_URLS } from "./go-doc.js";
|
|
26
30
|
import { readBoundedJson } from "./json-response.js";
|
|
27
31
|
import { readOnlineMetadata, toPiModel } from "./metadata.js";
|
|
28
32
|
export { PROVIDER_ID, DISPLAY_NAME, DEFAULT_BASE_URL } from "./constants.js";
|
|
@@ -80,6 +84,8 @@ export class OpencodeGoCatalog {
|
|
|
80
84
|
metadataDocument;
|
|
81
85
|
metadataETag;
|
|
82
86
|
lastMetadata;
|
|
87
|
+
/** The last parsed documentation; retained across failed fetches. */
|
|
88
|
+
document;
|
|
83
89
|
constructor(options) {
|
|
84
90
|
this.options = options;
|
|
85
91
|
}
|
|
@@ -118,12 +124,14 @@ export class OpencodeGoCatalog {
|
|
|
118
124
|
}
|
|
119
125
|
return snapshot;
|
|
120
126
|
}
|
|
121
|
-
/** Revalidate
|
|
127
|
+
/** Revalidate every source, reusing the metadata document when it is unchanged. */
|
|
122
128
|
async build() {
|
|
123
129
|
const builtin = builtinModels(this.options.baseURL);
|
|
124
|
-
const
|
|
130
|
+
const readDocument = this.options.readDocument ?? fetchGoDocument;
|
|
131
|
+
const [listing, metadata, documentation] = await Promise.allSettled([
|
|
125
132
|
fetchModelIds(this.options.baseURL),
|
|
126
133
|
this.refreshMetadata(builtin),
|
|
134
|
+
readDocument(),
|
|
127
135
|
]);
|
|
128
136
|
if (metadata.status === 'rejected') {
|
|
129
137
|
this.options.observers?.onFallback?.({
|
|
@@ -132,6 +140,18 @@ export class OpencodeGoCatalog {
|
|
|
132
140
|
kept: this.lastMetadata?.models.size ?? 0,
|
|
133
141
|
});
|
|
134
142
|
}
|
|
143
|
+
if (documentation.status === 'fulfilled') {
|
|
144
|
+
this.document = documentation.value;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Not a page-stale event: the allowances keep their previous source, and
|
|
148
|
+
// the page says so wherever the frozen seed is in use.
|
|
149
|
+
this.options.observers?.onFallback?.({
|
|
150
|
+
url: GO_DOC_URLS[0] ?? 'the Go documentation',
|
|
151
|
+
error: documentation.reason,
|
|
152
|
+
kept: this.document?.quotas.size ?? 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
135
155
|
if (listing.status === 'rejected') {
|
|
136
156
|
// Once observed, an outage must not resurrect retired models: the last
|
|
137
157
|
// successful listing stays authoritative until a new one arrives.
|
|
@@ -149,6 +169,7 @@ export class OpencodeGoCatalog {
|
|
|
149
169
|
live: false,
|
|
150
170
|
listingFailure: listing.reason,
|
|
151
171
|
fetchedAtMs: Date.now(),
|
|
172
|
+
...this.documentQuotas(),
|
|
152
173
|
};
|
|
153
174
|
}
|
|
154
175
|
// A metadata outage keeps the last successful parse: the previous document
|
|
@@ -177,7 +198,7 @@ export class OpencodeGoCatalog {
|
|
|
177
198
|
// (`@ai-sdk/openai-compatible`) makes Chat Completions the best available
|
|
178
199
|
// guess, and the route defaults size it; the settings surface marks it as
|
|
179
200
|
// inferred so a wrong guess is visible and overridable.
|
|
180
|
-
facts.set(id, this.inferredFacts(id, builtin));
|
|
201
|
+
facts.set(id, this.withDocumentedProtocol(this.inferredFacts(id, builtin)));
|
|
181
202
|
}
|
|
182
203
|
if (unavailable.size > 0) {
|
|
183
204
|
this.options.observers?.onUnconfigured?.([...unavailable].map(([id, reason]) => ({ id, reason })));
|
|
@@ -188,8 +209,28 @@ export class OpencodeGoCatalog {
|
|
|
188
209
|
provider: buildProvider(this.options.baseURL, [...facts.values()].map(fact => toPiModel(fact, this.options.baseURL))),
|
|
189
210
|
live: true,
|
|
190
211
|
fetchedAtMs: Date.now(),
|
|
212
|
+
...this.documentQuotas(),
|
|
191
213
|
};
|
|
192
214
|
}
|
|
215
|
+
/** The document allowances as a snapshot field, absent until one fetch lands. */
|
|
216
|
+
documentQuotas() {
|
|
217
|
+
return this.document === undefined ? {} : { documentQuotas: this.document.quotas };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Prefer the documented protocol over anything below the installed catalog.
|
|
221
|
+
*
|
|
222
|
+
* The endpoint table is the provider's own statement, so it outranks both the
|
|
223
|
+
* family guess and models.dev's SDK hint — this turns an `inferred` badge into
|
|
224
|
+
* a known answer for ids the installed catalog misses. It never overrides a
|
|
225
|
+
* builtin entry or a configured override: those carry the wire quirks the
|
|
226
|
+
* adapter relies on.
|
|
227
|
+
*/
|
|
228
|
+
withDocumentedProtocol(facts) {
|
|
229
|
+
if (facts.protocolSource !== 'inferred' && facts.protocolSource !== 'online')
|
|
230
|
+
return facts;
|
|
231
|
+
const documented = this.document?.protocols.get(facts.id);
|
|
232
|
+
return documented === undefined ? facts : { ...facts, api: documented, protocolSource: 'document' };
|
|
233
|
+
}
|
|
193
234
|
/** Facts for an id no source describes, from the ladder and the route defaults. */
|
|
194
235
|
inferredFacts(id, builtin) {
|
|
195
236
|
const exact = builtin.get(id);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded
|
|
2
|
+
* Bounded body reads shared by the catalog, the usage endpoint, and the Go
|
|
3
|
+
* documentation fetch.
|
|
3
4
|
*
|
|
4
|
-
* Every outbound metadata read is capped before parsing: the
|
|
5
|
-
*
|
|
5
|
+
* Every outbound metadata read is capped before parsing: the documents are
|
|
6
|
+
* third-party, and a truncated or oversized reply must surface as a named
|
|
6
7
|
* failure rather than as memory growth.
|
|
7
8
|
*
|
|
8
9
|
* @module @dan-ai-studio/dshopencodego/catalog/json-response
|
|
@@ -17,3 +18,5 @@
|
|
|
17
18
|
* or invalid JSON.
|
|
18
19
|
*/
|
|
19
20
|
export declare function readBoundedJson(response: Response, url: string, maxBytes: number): Promise<unknown>;
|
|
21
|
+
/** Decode a body as text, aborting once it passes the cap. */
|
|
22
|
+
export declare function readBoundedText(response: Response, maxBytes: number): Promise<string>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded
|
|
2
|
+
* Bounded body reads shared by the catalog, the usage endpoint, and the Go
|
|
3
|
+
* documentation fetch.
|
|
3
4
|
*
|
|
4
|
-
* Every outbound metadata read is capped before parsing: the
|
|
5
|
-
*
|
|
5
|
+
* Every outbound metadata read is capped before parsing: the documents are
|
|
6
|
+
* third-party, and a truncated or oversized reply must surface as a named
|
|
6
7
|
* failure rather than as memory growth.
|
|
7
8
|
*
|
|
8
9
|
* @module @dan-ai-studio/dshopencodego/catalog/json-response
|
|
@@ -39,8 +40,8 @@ export async function readBoundedJson(response, url, maxBytes) {
|
|
|
39
40
|
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error });
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
/** Decode a body, aborting once it passes the cap. */
|
|
43
|
-
async function readBoundedText(response, maxBytes) {
|
|
43
|
+
/** Decode a body as text, aborting once it passes the cap. */
|
|
44
|
+
export async function readBoundedText(response, maxBytes) {
|
|
44
45
|
if (response.body === null)
|
|
45
46
|
return '';
|
|
46
47
|
const reader = response.body.getReader();
|
|
@@ -24,8 +24,13 @@ import type { Api } from '@earendil-works/pi-ai';
|
|
|
24
24
|
export type WireProtocol = 'anthropic-messages' | 'openai-completions' | 'openai-responses';
|
|
25
25
|
/** Every protocol this plugin can drive, in catalog order. */
|
|
26
26
|
export declare const WIRE_PROTOCOLS: readonly WireProtocol[];
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Which evidence level produced a protocol decision, most authoritative first:
|
|
29
|
+
* a configured `override`, the installed catalog (`builtin`), the provider's own
|
|
30
|
+
* endpoint table (`document`), models.dev's SDK hint (`online`), and last the
|
|
31
|
+
* family rule (`inferred`).
|
|
32
|
+
*/
|
|
33
|
+
export type ProtocolSource = 'builtin' | 'document' | 'online' | 'inferred' | 'override';
|
|
29
34
|
/** One model's protocol decision with the evidence that produced it. */
|
|
30
35
|
export interface ProtocolDecision {
|
|
31
36
|
readonly api: WireProtocol;
|
|
@@ -21,6 +21,11 @@ export interface CatalogReading {
|
|
|
21
21
|
readonly error?: string;
|
|
22
22
|
/** When the underlying snapshot was built. */
|
|
23
23
|
readonly fetchedAtMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* Where the per-model allowances came from: the provider's live document, or
|
|
26
|
+
* the frozen seed (whose transcription date the page shows alongside).
|
|
27
|
+
*/
|
|
28
|
+
readonly quotaSource: 'document' | 'seed';
|
|
24
29
|
/** Counts the settings page shows without walking the list again. */
|
|
25
30
|
readonly counts: {
|
|
26
31
|
readonly total: number;
|
|
@@ -20,9 +20,10 @@ import { goQuotaFor } from "../go-limits.js";
|
|
|
20
20
|
* explain them instead of hiding them.
|
|
21
21
|
*/
|
|
22
22
|
export function catalogReading(snapshot, visibility, listingFailure) {
|
|
23
|
+
const documentQuotas = snapshot.documentQuotas;
|
|
23
24
|
const rows = [
|
|
24
25
|
...[...snapshot.facts.values()].map(fact => {
|
|
25
|
-
const quota = goQuotaFor(fact.id);
|
|
26
|
+
const quota = documentQuotas === undefined ? goQuotaFor(fact.id) : documentQuotas.get(fact.id);
|
|
26
27
|
const priced = fact.cost.input > 0 || fact.cost.output > 0;
|
|
27
28
|
return {
|
|
28
29
|
id: fact.id,
|
|
@@ -58,6 +59,7 @@ export function catalogReading(snapshot, visibility, listingFailure) {
|
|
|
58
59
|
stale: !snapshot.live,
|
|
59
60
|
...!snapshot.live && listingFailure !== undefined ? { error: listingFailure } : {},
|
|
60
61
|
fetchedAtMs: snapshot.fetchedAtMs,
|
|
62
|
+
quotaSource: documentQuotas === undefined ? 'seed' : 'document',
|
|
61
63
|
counts: {
|
|
62
64
|
total: models.length,
|
|
63
65
|
enabled,
|
package/lib/types/go-limits.d.ts
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Go's published per-model quota.
|
|
2
|
+
* The frozen seed of Go's published per-model quota.
|
|
3
3
|
*
|
|
4
|
-
* No API exposes
|
|
5
|
-
* `/usage` answers
|
|
6
|
-
*
|
|
4
|
+
* No API exposes these numbers — the gateway's `/v1/models` answers ids and
|
|
5
|
+
* `/usage` answers account-wide percentages — so the live figures are parsed
|
|
6
|
+
* from the provider's own documentation by `catalog/go-doc.ts`. This table is
|
|
7
|
+
* the seed and the last resort behind that fetch:
|
|
8
|
+
*
|
|
9
|
+
* - it renders instantly at startup, before the first fetch resolves;
|
|
10
|
+
* - it keeps working when every source fails, including the day the document
|
|
11
|
+
* URL moves.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately **not** updated as models arrive: wherever it is shown its
|
|
14
|
+
* transcription date travels with it, so a stale number is never silent, and a
|
|
15
|
+
* model it does not list simply shows no allowance until a fetch succeeds.
|
|
7
16
|
*
|
|
8
17
|
* Source: https://opencode.ai/docs/zh-cn/go — the 「使用限制」 and
|
|
9
|
-
* 「预估请求数」 tables. Transcribed 2026-09-25.
|
|
18
|
+
* 「预估请求数」 tables. Transcribed 2026-09-25, frozen 2026-09-26.
|
|
10
19
|
*
|
|
11
20
|
* Two caveats are part of the data's meaning, not noise:
|
|
12
21
|
* - `monthlyUsd` is the hard monthly allowance; the request counts are the
|
|
13
22
|
* provider's own estimate for a typical request mix, not a hard ceiling.
|
|
14
|
-
* - DeepSeek V4.1 Flash
|
|
15
|
-
*
|
|
23
|
+
* - DeepSeek V4.1 Flash carried a limited-time 4x allowance in this snapshot;
|
|
24
|
+
* the provider has since made the listed $60 permanent, which only the live
|
|
25
|
+
* document reports.
|
|
16
26
|
*
|
|
17
27
|
* @module @dan-ai-studio/dshopencodego/go-limits
|
|
18
28
|
*/
|
|
29
|
+
/** The date this seed was transcribed from the provider's page. */
|
|
30
|
+
export declare const SEED_TRANSCRIBED = "2026-09-25";
|
|
19
31
|
/** One model's published Go allowance. */
|
|
20
32
|
export interface GoQuota {
|
|
21
33
|
/** Monthly usage allowance in USD, or `unlimited` where none is published. */
|
|
@@ -23,12 +35,12 @@ export interface GoQuota {
|
|
|
23
35
|
/** Provider's estimated requests per month for a typical mix, when listed. */
|
|
24
36
|
readonly monthlyRequests?: number | 'unlimited';
|
|
25
37
|
}
|
|
26
|
-
/** The
|
|
27
|
-
export declare const
|
|
38
|
+
/** The frozen seed table, keyed by model id. Not a maintenance target. */
|
|
39
|
+
export declare const SEED_QUOTAS: Readonly<Record<string, GoQuota>>;
|
|
28
40
|
/**
|
|
29
|
-
* The
|
|
41
|
+
* The seed allowance for one model id.
|
|
30
42
|
* @param id - the gateway's model id.
|
|
31
|
-
* @returns the
|
|
43
|
+
* @returns the frozen seed entry, or undefined when the page listed none.
|
|
32
44
|
*/
|
|
33
45
|
export declare function goQuotaFor(id: string): GoQuota | undefined;
|
|
34
46
|
/**
|
package/lib/types/go-limits.js
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Go's published per-model quota.
|
|
2
|
+
* The frozen seed of Go's published per-model quota.
|
|
3
3
|
*
|
|
4
|
-
* No API exposes
|
|
5
|
-
* `/usage` answers
|
|
6
|
-
*
|
|
4
|
+
* No API exposes these numbers — the gateway's `/v1/models` answers ids and
|
|
5
|
+
* `/usage` answers account-wide percentages — so the live figures are parsed
|
|
6
|
+
* from the provider's own documentation by `catalog/go-doc.ts`. This table is
|
|
7
|
+
* the seed and the last resort behind that fetch:
|
|
8
|
+
*
|
|
9
|
+
* - it renders instantly at startup, before the first fetch resolves;
|
|
10
|
+
* - it keeps working when every source fails, including the day the document
|
|
11
|
+
* URL moves.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately **not** updated as models arrive: wherever it is shown its
|
|
14
|
+
* transcription date travels with it, so a stale number is never silent, and a
|
|
15
|
+
* model it does not list simply shows no allowance until a fetch succeeds.
|
|
7
16
|
*
|
|
8
17
|
* Source: https://opencode.ai/docs/zh-cn/go — the 「使用限制」 and
|
|
9
|
-
* 「预估请求数」 tables. Transcribed 2026-09-25.
|
|
18
|
+
* 「预估请求数」 tables. Transcribed 2026-09-25, frozen 2026-09-26.
|
|
10
19
|
*
|
|
11
20
|
* Two caveats are part of the data's meaning, not noise:
|
|
12
21
|
* - `monthlyUsd` is the hard monthly allowance; the request counts are the
|
|
13
22
|
* provider's own estimate for a typical request mix, not a hard ceiling.
|
|
14
|
-
* - DeepSeek V4.1 Flash
|
|
15
|
-
*
|
|
23
|
+
* - DeepSeek V4.1 Flash carried a limited-time 4x allowance in this snapshot;
|
|
24
|
+
* the provider has since made the listed $60 permanent, which only the live
|
|
25
|
+
* document reports.
|
|
16
26
|
*
|
|
17
27
|
* @module @dan-ai-studio/dshopencodego/go-limits
|
|
18
28
|
*/
|
|
19
|
-
/** The transcribed
|
|
20
|
-
export const
|
|
29
|
+
/** The date this seed was transcribed from the provider's page. */
|
|
30
|
+
export const SEED_TRANSCRIBED = '2026-09-25';
|
|
31
|
+
/** The frozen seed table, keyed by model id. Not a maintenance target. */
|
|
32
|
+
export const SEED_QUOTAS = {
|
|
21
33
|
'glm-5.3-flash': { monthlyUsd: 60, monthlyRequests: 31_580 },
|
|
22
34
|
'glm-5.3': { monthlyUsd: 15, monthlyRequests: 1_080 },
|
|
23
35
|
'glm-5.2': { monthlyUsd: 60, monthlyRequests: 4_300 },
|
|
@@ -53,12 +65,12 @@ export const GO_QUOTAS = {
|
|
|
53
65
|
'gpt-5.6-luna': { monthlyUsd: 15, monthlyRequests: 10_250 },
|
|
54
66
|
};
|
|
55
67
|
/**
|
|
56
|
-
* The
|
|
68
|
+
* The seed allowance for one model id.
|
|
57
69
|
* @param id - the gateway's model id.
|
|
58
|
-
* @returns the
|
|
70
|
+
* @returns the frozen seed entry, or undefined when the page listed none.
|
|
59
71
|
*/
|
|
60
72
|
export function goQuotaFor(id) {
|
|
61
|
-
return Object.hasOwn(
|
|
73
|
+
return Object.hasOwn(SEED_QUOTAS, id) ? SEED_QUOTAS[id] : undefined;
|
|
62
74
|
}
|
|
63
75
|
/**
|
|
64
76
|
* Rank a quota for "most usable first" ordering: unlimited beats any finite
|
package/package.json
CHANGED