Repository navigation
Expand file tree
/
Copy pathmodels.mjs
More file actions
164 lines (147 loc) · 5.48 KB
/
Copy pathmodels.mjs
File metadata and controls
164 lines (147 loc) · 5.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
* Model discovery for InsCode free quota.
*
* The taotoken gateway reports what an account can use at `GET /v1/models`
* (plain Bearer, no signing) — `{ data: [{ id, model, name, type }] }`. The
* live list is the source of truth once a signed-in account exists; for a
* low-tier account it is smaller.
*
* `FALLBACK_MODELS` is the snapshot that keeps the provider selectable before a
* sign-in or when the fetch fails. The model *key* (what agents address by) is
* sanitized; the original id rides along as `api.id` because that is the id the
* upstream gateway expects in request bodies.
*
* @module magpie-inscode/models
*/
import { DEFAULT_API_BASE } from "./internal.mjs";
/** The AI SDK package that speaks the OpenAI-compatible dialect. */
export const NPM = "@ai-sdk/openai-compatible";
/** ChatGPT gateway root; loader requests and `api.url` default to it. */
export const DEFAULT_BASE_URL = DEFAULT_API_BASE;
/** Fallbacks when a row lacks a context/output window. */
export const DEFAULT_CONTEXT = 128000;
export const DEFAULT_OUTPUT = 8192;
/** How long one account's fetched list is trusted. */
export const MODELS_TTL_MS = 10 * 60_000;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
/**
* Model keys may contain `/` or other characters that complicate addressing;
* the unsanitized id stays in `api.id`, so the wire never sees the mangled one.
*/
export function sanitizeModelKey(value) {
const id = String(value ?? "").trim();
const key = id.replace(/[^A-Za-z0-9._-]/g, "_");
return key || id;
}
/** The snapshot kept before sign-in / on fetch failure (observed free quota). */
export const FALLBACK_MODELS = [
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", context: 128000, output: 8192 },
];
/**
* Normalize one `/v1/models` row (or a `FALLBACK_MODELS` entry, same shape).
*
* @returns {{id: string, key: string, name: string, context: number, output: number}|null}
*/
export function normalizeRow(raw = {}, { output = DEFAULT_OUTPUT } = {}) {
const id = String(raw?.id ?? raw?.model ?? "").trim();
if (!id) return null;
const name = String(raw?.name ?? "").trim() || id;
return {
id,
key: sanitizeModelKey(id),
name,
context: num(raw.context_window) > 0 ? num(raw.context_window) : DEFAULT_CONTEXT,
output,
};
}
/** The `config` hook's entry declaring one model. */
export function toConfigModel(m = {}) {
return {
name: m.name ?? m.id ?? m.key,
tool_call: true,
limit: { context: m.context ?? DEFAULT_CONTEXT, output: m.output ?? DEFAULT_OUTPUT },
...(m.key && m.id && m.key !== m.id ? { id: m.id } : {}),
};
}
/** The `provider.models` entry for one model, in Magpie's model shape. */
export function toProviderModel(m = {}, providerID, { baseURL = DEFAULT_BASE_URL, npm = NPM } = {}) {
return {
id: m.key,
providerID,
name: m.name ?? m.id ?? m.key,
api: { id: m.id ?? m.key, url: baseURL, npm },
status: "active",
headers: {},
options: {},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: m.context ?? DEFAULT_CONTEXT, output: m.output ?? DEFAULT_OUTPUT },
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true },
interleaved: false,
},
release_date: "",
variants: {},
};
}
/** Declared (config-hook) models from rows in `FALLBACK_MODELS` shape. */
export function seedModels(rows = FALLBACK_MODELS) {
return Object.fromEntries(
rows
.map((raw) => normalizeRow(raw))
.filter(Boolean)
.map((m) => [m.key, toConfigModel(m)]),
);
}
/**
* One account's catalog, fetched once and cached.
*
* Only the account's own key hits the wire, so two accounts never share a cache
* entry. Non-2xx throws (with `signIn: "expired"` on 401 so the caller can mark
* the account); the provider hook decides between fallback and account marking.
*
* @param {{apiBase?: string, ttlMs?: number, output?: number, fetchImpl?: Function}} opts
*/
export function createCatalog({
apiBase = DEFAULT_BASE_URL,
ttlMs = MODELS_TTL_MS,
output = DEFAULT_OUTPUT,
fetchImpl,
} = {}) {
const cache = new Map();
const inflight = new Map();
const fetchNow = fetchImpl ?? ((...args) => fetch(...args));
async function load(credentials, { output: outputOverride = output } = {}) {
if (!credentials?.key) return [];
const key = credentials.accountId || credentials.configDir || credentials.key;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttlMs) return hit.models;
const pending = inflight.get(key);
if (pending) return pending;
const p = (async () => {
const res = await fetchNow(`${apiBase}/models`, {
headers: { authorization: `Bearer ${credentials.key}`, accept: "application/json" },
});
if (res.status === 401) {
throw Object.assign(new Error(`GET /v1/models -> 401`), { signIn: "expired" });
}
if (!res.ok) throw new Error(`GET /v1/models -> ${res.status}`);
const { data } = await res.json().catch(() => ({}));
const models = (Array.isArray(data) ? data : [])
.map((row) => normalizeRow(row, { output: outputOverride }))
.filter(Boolean);
if (models.length) cache.set(key, { at: Date.now(), models });
return models;
})().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
return { load };
}