ldrouter 1.16.2 → 1.16.4
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/CHANGELOG.md +23 -0
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +58 -55
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -3
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +31 -4
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +74 -14
- package/dist/server/routing/combo.js +8 -19
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +17 -7
- package/dist/web/assets/index-2L7raen4.js +391 -0
- package/dist/web/assets/index-Drmqi8MO.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
|
@@ -14,11 +14,24 @@ const ModelUpdate = z.object({
|
|
|
14
14
|
displayName: z.string().min(1).max(128).optional(),
|
|
15
15
|
enabled: z.boolean().optional(),
|
|
16
16
|
upstreamAvailable: z.boolean().optional(),
|
|
17
|
-
|
|
17
|
+
// Values may be true / false / null. null clears the key back to "unknown",
|
|
18
|
+
// which the router treats as "not verified" rather than "unsupported".
|
|
19
|
+
capabilities: z.record(z.union([z.boolean(), z.null()])).optional(),
|
|
18
20
|
cacheOverrideEnabled: z.boolean().nullable().optional(),
|
|
19
21
|
maxContextTokens: z.number().int().min(1).nullable().optional(),
|
|
20
22
|
maxOutputTokens: z.number().int().min(1).nullable().optional(),
|
|
21
23
|
});
|
|
24
|
+
/** Apply an admin capability override. `null` removes the key (= unknown). */
|
|
25
|
+
function applyCapabilityOverrides(stored, patch) {
|
|
26
|
+
const out = { ...stored };
|
|
27
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
28
|
+
if (v === null)
|
|
29
|
+
delete out[k];
|
|
30
|
+
else
|
|
31
|
+
out[k] = v;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
22
35
|
export async function registerModelRoutes(app) {
|
|
23
36
|
app.addHook('preHandler', requireAdminAuth);
|
|
24
37
|
app.get('/api/admin/models', async (req) => {
|
|
@@ -54,6 +67,7 @@ export async function registerModelRoutes(app) {
|
|
|
54
67
|
enabled: m.enabled,
|
|
55
68
|
upstreamAvailable: m.upstreamAvailable,
|
|
56
69
|
capabilities: safeJson(m.capabilitiesJson),
|
|
70
|
+
discoveredCapabilities: m.discoveredMetadataJson ? safeJson(m.discoveredMetadataJson) : null,
|
|
57
71
|
maxContextTokens: m.maxContextTokens,
|
|
58
72
|
maxOutputTokens: m.maxOutputTokens,
|
|
59
73
|
lastSeenUpstreamAt: m.lastSeenUpstreamAt,
|
|
@@ -69,7 +83,7 @@ export async function registerModelRoutes(app) {
|
|
|
69
83
|
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
70
84
|
// Fetch discovered model metadata fresh (so import uses current discovery data)
|
|
71
85
|
// For simplicity: re-discover and match by upstream id.
|
|
72
|
-
const { discoverProviderModels } = await import('../../providers/index.js');
|
|
86
|
+
const { discoverProviderModels, mergeDiscoveredCapabilities } = await import('../../providers/index.js');
|
|
73
87
|
const { decryptSecret, decryptCustomHeaders } = await import('../../auth/crypto.js');
|
|
74
88
|
const { codexModels } = await import('../../providers/codex.js');
|
|
75
89
|
const { getCodexAccountById, listCodexAccountSummaries } = await import('../../db/repositories/codex-accounts.js');
|
|
@@ -105,7 +119,19 @@ export async function registerModelRoutes(app) {
|
|
|
105
119
|
const disc = discMap.get(upstreamId);
|
|
106
120
|
const caps = disc?.capabilities ?? { chat: true, streaming: true, tools: true };
|
|
107
121
|
if (existing) {
|
|
108
|
-
|
|
122
|
+
// Refresh capabilities so stale discoveries (e.g. a wrong
|
|
123
|
+
// `image_input: false`) heal on re-import, while admin edits survive.
|
|
124
|
+
const merged = mergeDiscoveredCapabilities(safeJson(existing.capabilitiesJson), existing.discoveredMetadataJson ? safeJson(existing.discoveredMetadataJson) : null, caps);
|
|
125
|
+
db.update(schema.models)
|
|
126
|
+
.set({
|
|
127
|
+
upstreamAvailable: true,
|
|
128
|
+
lastSeenUpstreamAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
capabilitiesJson: JSON.stringify(merged.capabilities),
|
|
131
|
+
discoveredMetadataJson: JSON.stringify(merged.baseline),
|
|
132
|
+
})
|
|
133
|
+
.where(eq(schema.models.id, existing.id))
|
|
134
|
+
.run();
|
|
109
135
|
continue;
|
|
110
136
|
}
|
|
111
137
|
const publicModelId = `${provider.slug}/${upstreamId}`;
|
|
@@ -118,6 +144,7 @@ export async function registerModelRoutes(app) {
|
|
|
118
144
|
enabled: true,
|
|
119
145
|
upstreamAvailable: true,
|
|
120
146
|
capabilitiesJson: JSON.stringify(caps),
|
|
147
|
+
discoveredMetadataJson: JSON.stringify(caps),
|
|
121
148
|
maxContextTokens: typeof caps.max_context_tokens === 'number' ? caps.max_context_tokens : null,
|
|
122
149
|
maxOutputTokens: typeof caps.max_output_tokens === 'number' ? caps.max_output_tokens : null,
|
|
123
150
|
lastSeenUpstreamAt: now,
|
|
@@ -141,7 +168,7 @@ export async function registerModelRoutes(app) {
|
|
|
141
168
|
if (body.upstreamAvailable !== undefined)
|
|
142
169
|
update.upstreamAvailable = body.upstreamAvailable;
|
|
143
170
|
if (body.capabilities) {
|
|
144
|
-
const merged =
|
|
171
|
+
const merged = applyCapabilityOverrides(safeJson(m.capabilitiesJson), body.capabilities);
|
|
145
172
|
update.capabilitiesJson = JSON.stringify(merged);
|
|
146
173
|
if (typeof merged.max_context_tokens === 'number')
|
|
147
174
|
update.maxContextTokens = merged.max_context_tokens;
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { authenticateGatewayKey } from '../../auth/api-key.js';
|
|
4
4
|
import { resolveClientIp } from '../../util/client-ip.js';
|
|
5
5
|
import { anthropicToCanonical } from '../../protocols/anthropic.js';
|
|
6
|
-
import { GatewayError, toAnthropicError } from '../../errors.js';
|
|
6
|
+
import { GatewayError, toAnthropicError, outcomeError } from '../../errors.js';
|
|
7
7
|
import { GatewayRunner } from '../../gateway/runner.js';
|
|
8
8
|
import { uuid } from '../../auth/ids.js';
|
|
9
9
|
import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
|
|
@@ -58,7 +58,7 @@ export async function registerAnthropicRoutes(app) {
|
|
|
58
58
|
return reply;
|
|
59
59
|
}
|
|
60
60
|
if (!outcome.success) {
|
|
61
|
-
const g =
|
|
61
|
+
const g = outcomeError(outcome);
|
|
62
62
|
reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
@@ -66,7 +66,7 @@ export async function registerAnthropicRoutes(app) {
|
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
if (!outcome.success) {
|
|
69
|
-
const g =
|
|
69
|
+
const g = outcomeError(outcome);
|
|
70
70
|
lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
|
|
71
71
|
reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
|
|
72
72
|
return;
|
|
@@ -5,7 +5,7 @@ import { getDb, schema } from '../../db/index.js';
|
|
|
5
5
|
import { authenticateGatewayKey } from '../../auth/api-key.js';
|
|
6
6
|
import { resolveClientIp } from '../../util/client-ip.js';
|
|
7
7
|
import { openAIToCanonical, openAIModelList } from '../../protocols/canonical.js';
|
|
8
|
-
import { GatewayError, toOpenAIError } from '../../errors.js';
|
|
8
|
+
import { GatewayError, toOpenAIError, outcomeError } from '../../errors.js';
|
|
9
9
|
import { GatewayRunner } from '../../gateway/runner.js';
|
|
10
10
|
import { uuid } from '../../auth/ids.js';
|
|
11
11
|
import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeBody, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
|
|
@@ -86,7 +86,7 @@ export async function registerOpenAIRoutes(app) {
|
|
|
86
86
|
// the client should get a regular protocol error instead of a dangling
|
|
87
87
|
// stream.
|
|
88
88
|
if (!outcome.success) {
|
|
89
|
-
const g =
|
|
89
|
+
const g = outcomeError(outcome);
|
|
90
90
|
reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
@@ -95,7 +95,7 @@ export async function registerOpenAIRoutes(app) {
|
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
if (!outcome.success) {
|
|
98
|
-
const g =
|
|
98
|
+
const g = outcomeError(outcome);
|
|
99
99
|
lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
|
|
100
100
|
reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
|
|
101
101
|
return;
|
|
@@ -176,7 +176,7 @@ export async function registerOpenAIRoutes(app) {
|
|
|
176
176
|
return reply;
|
|
177
177
|
}
|
|
178
178
|
if (!outcome.success) {
|
|
179
|
-
const g =
|
|
179
|
+
const g = outcomeError(outcome);
|
|
180
180
|
reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
@@ -184,7 +184,7 @@ export async function registerOpenAIRoutes(app) {
|
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
if (!outcome.success) {
|
|
187
|
-
const g =
|
|
187
|
+
const g = outcomeError(outcome);
|
|
188
188
|
reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
|
|
189
189
|
return;
|
|
190
190
|
}
|
|
@@ -33,6 +33,36 @@ export function deriveRequiredCapabilities(req) {
|
|
|
33
33
|
responses: false,
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The single table of "explicitly unsupported" checks. `modelMeets` and the
|
|
38
|
+
* rejection reporter both read it, so they can never disagree about a model.
|
|
39
|
+
* `reasoning` is deliberately absent: it is advisory metadata, because an
|
|
40
|
+
* upstream may support reasoning even when discovery cannot identify it.
|
|
41
|
+
*/
|
|
42
|
+
const CAPABILITY_CHECKS = [
|
|
43
|
+
{ flag: 'streaming', required: 'streaming', reason: 'streaming', label: 'no streaming' },
|
|
44
|
+
{ flag: 'tools', required: 'tools', reason: 'tools', label: 'no tool calling' },
|
|
45
|
+
{ flag: 'structured_output', required: 'structuredOutput', reason: 'structured_output', label: 'no structured output' },
|
|
46
|
+
{ flag: 'image_input', required: 'imageInput', reason: 'image_input', label: 'no image input' },
|
|
47
|
+
{ flag: 'audio_input', required: 'audioInput', reason: 'audio_input', label: 'no audio input' },
|
|
48
|
+
{ flag: 'responses', required: 'responses', reason: 'responses', label: 'no Responses API support' },
|
|
49
|
+
];
|
|
50
|
+
const NON_CAPABILITY_TEXT = {
|
|
51
|
+
model_not_found: 'model not found',
|
|
52
|
+
provider_not_found: 'provider not found',
|
|
53
|
+
provider_disabled: 'provider disabled',
|
|
54
|
+
model_disabled: 'model disabled',
|
|
55
|
+
upstream_unavailable: 'not available upstream',
|
|
56
|
+
circuit_open: 'provider circuit is open',
|
|
57
|
+
combo_disabled: 'combo disabled',
|
|
58
|
+
member_disabled: 'disabled in this combo',
|
|
59
|
+
codex_account_unavailable: 'no available Codex account',
|
|
60
|
+
};
|
|
61
|
+
const REASON_TEXT = {
|
|
62
|
+
...NON_CAPABILITY_TEXT,
|
|
63
|
+
...Object.fromEntries(CAPABILITY_CHECKS.map((c) => [c.reason, c.label])),
|
|
64
|
+
};
|
|
65
|
+
const CAPABILITY_REASONS = new Set(CAPABILITY_CHECKS.map((c) => c.reason));
|
|
36
66
|
/**
|
|
37
67
|
* Check if a model meets required capabilities.
|
|
38
68
|
* IMPORTANT: Treat undefined as "unknown" rather than "unsupported".
|
|
@@ -43,18 +73,48 @@ export function deriveRequiredCapabilities(req) {
|
|
|
43
73
|
* upstream may support it even when discovery cannot identify it.
|
|
44
74
|
*/
|
|
45
75
|
export function modelMeets(caps, req) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
return firstMissingCapability(caps, req) === null;
|
|
77
|
+
}
|
|
78
|
+
/** The first capability this model is KNOWN not to support (undefined = unknown
|
|
79
|
+
* caps never reject), or null when nothing required is explicitly missing. */
|
|
80
|
+
export function firstMissingCapability(caps, req) {
|
|
81
|
+
for (const c of CAPABILITY_CHECKS) {
|
|
82
|
+
if (req[c.required] && caps[c.flag] === false)
|
|
83
|
+
return c.reason;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `vl/gpt-5.5` is reported to clients as `gpt-5.5`: the provider prefix is the
|
|
89
|
+
* gateway's bookkeeping, not the operator's or the API client's vocabulary.
|
|
90
|
+
*/
|
|
91
|
+
export function bareModelName(publicModelId) {
|
|
92
|
+
const i = publicModelId.indexOf('/');
|
|
93
|
+
return i === -1 ? publicModelId : publicModelId.slice(i + 1);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Turn per-model rejection reasons into one client-facing error that names every
|
|
97
|
+
* excluded model and why. The old blanket strings ("No combo member satisfies
|
|
98
|
+
* the request capabilities or availability", "No available model candidates")
|
|
99
|
+
* told the caller nothing it could act on — docs/13 §10 records that as a bug.
|
|
100
|
+
*
|
|
101
|
+
* A capability miss is deterministic, so it wins the status code (400); reasons
|
|
102
|
+
* of mere *availability* are still listed so nothing is hidden. When no
|
|
103
|
+
* capability was involved the targetis simply unavailable (502).
|
|
104
|
+
*/
|
|
105
|
+
export function describeRejections(target, rejected) {
|
|
106
|
+
const groups = new Map();
|
|
107
|
+
for (const r of rejected) {
|
|
108
|
+
const label = REASON_TEXT[r.reason] ?? r.reason;
|
|
109
|
+
groups.set(label, [...(groups.get(label) ?? []), bareModelName(r.publicModelId)]);
|
|
110
|
+
}
|
|
111
|
+
// A direct model is also the target, so naming it twice would only be noise.
|
|
112
|
+
const detail = target.kind === 'model'
|
|
113
|
+
? [...groups.keys()].join('; ')
|
|
114
|
+
: [...groups].map(([label, names]) => `${[...new Set(names)].join(', ')} (${label})`).join('; ');
|
|
115
|
+
const subject = target.kind === 'combo' ? `Combo "${target.publicModelId}"` : `Model "${bareModelName(target.publicModelId)}"`;
|
|
116
|
+
const message = `${subject} cannot serve this request: ${detail || 'no usable member'}`;
|
|
117
|
+
return rejected.some((r) => CAPABILITY_REASONS.has(r.reason))
|
|
118
|
+
? { message, type: 'capability_not_supported', status: 400 }
|
|
119
|
+
: { message, type: 'upstream_unavailable', status: 502 };
|
|
60
120
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Combo routing: fallback (ordered) or weighted round-robin.
|
|
2
2
|
import { eq } from 'drizzle-orm';
|
|
3
3
|
import { getDb, schema } from '../db/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import { firstMissingCapability } from './capabilities.js';
|
|
5
5
|
export function loadCombo(comboId) {
|
|
6
6
|
const db = getDb();
|
|
7
7
|
const c = db.select().from(schema.combos).where(eq(schema.combos.id, comboId)).get();
|
|
@@ -44,6 +44,10 @@ export function selectCandidates(combo, allModels, req, onReject) {
|
|
|
44
44
|
onReject?.({ modelId: m.modelId, publicModelId: m.modelId }, 'model_not_found');
|
|
45
45
|
continue;
|
|
46
46
|
}
|
|
47
|
+
if (c.providerEnabled === false) {
|
|
48
|
+
onReject?.(c, 'provider_disabled');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
47
51
|
if (!c.enabled) {
|
|
48
52
|
onReject?.(c, 'model_disabled');
|
|
49
53
|
continue;
|
|
@@ -56,30 +60,15 @@ export function selectCandidates(combo, allModels, req, onReject) {
|
|
|
56
60
|
onReject?.(c, 'circuit_open');
|
|
57
61
|
continue;
|
|
58
62
|
}
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const missing = firstMissingCapability(c.capabilities, req);
|
|
64
|
+
if (missing) {
|
|
65
|
+
onReject?.(c, missing);
|
|
61
66
|
continue;
|
|
62
67
|
}
|
|
63
68
|
candidates.push(c);
|
|
64
69
|
}
|
|
65
70
|
return candidates;
|
|
66
71
|
}
|
|
67
|
-
/** First capability that explicitly failed (undefined = unknown caps never reject). */
|
|
68
|
-
function capabilityRejection(caps, req) {
|
|
69
|
-
if (req.streaming && caps.streaming === false)
|
|
70
|
-
return 'streaming';
|
|
71
|
-
if (req.tools && caps.tools === false)
|
|
72
|
-
return 'tools';
|
|
73
|
-
if (req.structuredOutput && caps.structured_output === false)
|
|
74
|
-
return 'structured_output';
|
|
75
|
-
if (req.imageInput && caps.image_input === false)
|
|
76
|
-
return 'image_input';
|
|
77
|
-
if (req.audioInput && caps.audio_input === false)
|
|
78
|
-
return 'audio_input';
|
|
79
|
-
if (req.responses && caps.responses === false)
|
|
80
|
-
return 'responses';
|
|
81
|
-
return 'capability_mismatch';
|
|
82
|
-
}
|
|
83
72
|
export function orderCandidates(combo, candidates) {
|
|
84
73
|
if (combo.mode === 'fallback') {
|
|
85
74
|
// Preserve declared position order
|
|
@@ -2,37 +2,42 @@
|
|
|
2
2
|
import { eq, and } from 'drizzle-orm';
|
|
3
3
|
import { getDb, schema } from '../db/index.js';
|
|
4
4
|
import { GatewayError } from '../errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolution is deliberately blind to `enabled`. A disabled model is NOT an
|
|
7
|
+
* unknown model: reporting it as `Unknown model: X` (404) sent operators hunting
|
|
8
|
+
* for a typo when the real answer was "you turned this off". The `enabled` flag
|
|
9
|
+
* travels with the target so the caller can say which it is.
|
|
10
|
+
*/
|
|
5
11
|
export function resolveRequestedModel(requested) {
|
|
6
12
|
const db = getDb();
|
|
7
13
|
// Physical model
|
|
8
14
|
const model = db.select().from(schema.models).where(eq(schema.models.publicModelId, requested)).get();
|
|
9
|
-
if (model
|
|
10
|
-
return { kind: 'model', modelId: model.id, publicModelId: model.publicModelId };
|
|
11
|
-
}
|
|
15
|
+
if (model)
|
|
16
|
+
return { kind: 'model', modelId: model.id, publicModelId: model.publicModelId, enabled: model.enabled };
|
|
12
17
|
// Combo by exact public ID — with-prefix ("combo/<slug>") or the
|
|
13
18
|
// prefix-less default (<slug>).
|
|
14
19
|
const combo = db.select().from(schema.combos).where(eq(schema.combos.publicModelId, requested)).get();
|
|
15
|
-
if (combo
|
|
16
|
-
return { kind: 'combo', comboId: combo.id, publicModelId: combo.publicModelId };
|
|
20
|
+
if (combo)
|
|
21
|
+
return { kind: 'combo', comboId: combo.id, publicModelId: combo.publicModelId, enabled: combo.enabled };
|
|
17
22
|
// Alias (one hop only)
|
|
18
23
|
const alias = db.select().from(schema.modelAliases).where(and(eq(schema.modelAliases.alias, requested), eq(schema.modelAliases.enabled, true))).get();
|
|
19
24
|
if (alias) {
|
|
20
25
|
if (alias.targetKind === 'model') {
|
|
21
26
|
const m = db.select().from(schema.models).where(eq(schema.models.id, alias.targetId)).get();
|
|
22
|
-
if (m
|
|
23
|
-
return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'model', modelId: m.id, publicModelId: m.publicModelId } };
|
|
27
|
+
if (m)
|
|
28
|
+
return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'model', modelId: m.id, publicModelId: m.publicModelId, enabled: m.enabled } };
|
|
24
29
|
}
|
|
25
30
|
else {
|
|
26
31
|
const c = db.select().from(schema.combos).where(eq(schema.combos.id, alias.targetId)).get();
|
|
27
|
-
if (c
|
|
28
|
-
return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId } };
|
|
32
|
+
if (c)
|
|
33
|
+
return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId, enabled: c.enabled } };
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
// Maybe the user typed the combo slug without the prefix
|
|
32
37
|
if (!requested.includes('/')) {
|
|
33
38
|
const c = db.select().from(schema.combos).where(eq(schema.combos.slug, requested)).get();
|
|
34
|
-
if (c
|
|
35
|
-
return { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId };
|
|
39
|
+
if (c)
|
|
40
|
+
return { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId, enabled: c.enabled };
|
|
36
41
|
}
|
|
37
42
|
throw new GatewayError('model_not_found', `Unknown model: ${requested}`, { status: 404 });
|
|
38
43
|
}
|
|
@@ -51,6 +51,22 @@ export function providerToUpstreamConfig(p, codexAccountId) {
|
|
|
51
51
|
totalTimeoutMs: p.totalTimeoutMs,
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* One conversion of an upstream HTTP status into a gateway error, shared by the
|
|
56
|
+
* streaming and non-streaming paths so both report identical type/code/status.
|
|
57
|
+
* Before this, the same upstream 429 arrived as "Upstream rate limited" over a
|
|
58
|
+
* stream and "Upstream rate limited (HTTP 429)" without one, with a different
|
|
59
|
+
* code (or none) each time.
|
|
60
|
+
*/
|
|
61
|
+
export function upstreamHttpError(status, bodyExcerpt, cause) {
|
|
62
|
+
if (status >= 500)
|
|
63
|
+
return new GatewayError('upstream_error', `Upstream HTTP ${status}`, { status: 502, cause, code: `upstream_http_${status}` });
|
|
64
|
+
if (status === 429)
|
|
65
|
+
return new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${status})`, { status: 429, cause, code: 'upstream_http_429' });
|
|
66
|
+
if (status === 401 || status === 403)
|
|
67
|
+
return new GatewayError('upstream_auth_error', `Upstream authentication failed (HTTP ${status})`, { status: 502, cause, code: `upstream_http_${status}` });
|
|
68
|
+
return new GatewayError('upstream_error', `Upstream HTTP ${status}: ${bodyExcerpt}`, { status: 502, cause, code: `upstream_http_${status}` });
|
|
69
|
+
}
|
|
54
70
|
export async function callUpstreamNonStreaming(cfg, url, payload, requestId = '-') {
|
|
55
71
|
const ctl = new AbortController();
|
|
56
72
|
const timer = setTimeout(() => ctl.abort(), cfg.totalTimeoutMs);
|
|
@@ -190,13 +206,7 @@ export async function callUpstreamStreaming(cfg, url, payload, onChunk, requestI
|
|
|
190
206
|
}
|
|
191
207
|
catch (e) {
|
|
192
208
|
if (e instanceof UpstreamHttpError) {
|
|
193
|
-
|
|
194
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${e.status}`, { status: 502, cause: e, code: 'upstream_http_' + e.status });
|
|
195
|
-
if (e.status === 429)
|
|
196
|
-
throw new GatewayError('upstream_rate_limit', 'Upstream rate limited', { status: 529, cause: e });
|
|
197
|
-
if (e.status === 401 || e.status === 403)
|
|
198
|
-
throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502, cause: e });
|
|
199
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${e.status}: ${e.bodyExcerpt}`, { status: 502, cause: e });
|
|
209
|
+
throw upstreamHttpError(e.status, e.bodyExcerpt, e);
|
|
200
210
|
}
|
|
201
211
|
const err = e;
|
|
202
212
|
errorLine(requestId, 'UPSTREAM FETCH ERROR', [
|