@providerkit/core 0.22.0 → 0.24.0
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.md +29 -7
- package/dist/auth/flows.d.ts.map +1 -1
- package/dist/auth/flows.js +5 -5
- package/dist/auth/flows.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +19 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/token-source.d.ts +9 -3
- package/dist/auth/token-source.d.ts.map +1 -1
- package/dist/auth/token-source.js +24 -9
- package/dist/auth/token-source.js.map +1 -1
- package/dist/auth/with-token.d.ts +32 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +88 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/context.d.ts +39 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +119 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +12 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -5
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +45 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +138 -75
- package/dist/fallback.js.map +1 -1
- package/dist/gate.d.ts +29 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +96 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +21 -4
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +43 -12
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +7 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +12 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +7 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +83 -26
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +26 -0
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +41 -9
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +6 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +102 -27
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +66 -15
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +11 -0
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +101 -53
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts +10 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +7 -1
- package/dist/rate-limit.js.map +1 -1
- package/dist/registry.d.ts +105 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +291 -0
- package/dist/registry.js.map +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +25 -12
- package/dist/retry.js.map +1 -1
- package/dist/tool-args.d.ts +22 -0
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +28 -0
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +34 -4
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +77 -16
- package/dist/transport.js.map +1 -1
- package/dist/turn.d.ts +55 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +125 -0
- package/dist/turn.js.map +1 -0
- package/dist/types.d.ts +21 -22
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -53
- package/dist/types.js.map +1 -1
- package/dist/watchdog.d.ts +2 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +25 -6
- package/dist/watchdog.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/flows.ts +5 -4
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.ts +23 -1
- package/src/auth/token-source.ts +30 -11
- package/src/auth/with-token.ts +100 -0
- package/src/context.ts +137 -2
- package/src/errors.ts +46 -6
- package/src/fallback.ts +173 -75
- package/src/gate.ts +152 -0
- package/src/index.ts +3 -0
- package/src/key-pool.ts +45 -13
- package/src/presets.ts +12 -2
- package/src/providers/anthropic.ts +93 -25
- package/src/providers/factory.ts +66 -21
- package/src/providers/gemini.ts +102 -28
- package/src/providers/openai.ts +71 -14
- package/src/providers/responses.ts +116 -51
- package/src/rate-limit.ts +31 -3
- package/src/registry.ts +420 -0
- package/src/retry.ts +41 -10
- package/src/tool-args.ts +44 -0
- package/src/tools.ts +10 -2
- package/src/transport.ts +97 -17
- package/src/turn.ts +166 -0
- package/src/types.ts +21 -62
- package/src/watchdog.ts +25 -6
package/src/gate.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// How many calls run at once on one connection.
|
|
2
|
+
//
|
|
3
|
+
// A flat-rate plan caps concurrent requests, and says so only by failing: six
|
|
4
|
+
// calls at once on one Z.ai Coding Plan key came back with some 429s (measured
|
|
5
|
+
// 2026-09-25, `capability.ts`). Behind a fallback chain the overflow is
|
|
6
|
+
// answered by the NEXT model, so the cap shows up as a quietly different
|
|
7
|
+
// answer, not as an error. The capability probe runs one call at a time for
|
|
8
|
+
// that reason, and one adopter runs its batches one item at a time on purpose.
|
|
9
|
+
// A gate holds the overflow in a queue instead, and lets each call in as a
|
|
10
|
+
// slot frees.
|
|
11
|
+
//
|
|
12
|
+
// The hard part is giving the slot back. A slot leaked by an abandoned stream
|
|
13
|
+
// is gone for the life of the process, with no error anywhere: the connection
|
|
14
|
+
// just serves one call fewer, until it serves none. So the slot is taken on the
|
|
15
|
+
// first read (a stream nobody reads holds nothing) and released exactly once,
|
|
16
|
+
// on whichever comes first: the stream ends, throws or is returned, or its
|
|
17
|
+
// signal aborts. That last one matters when the consumer holds a stream and
|
|
18
|
+
// stops reading it. Nothing else would ever free that slot.
|
|
19
|
+
//
|
|
20
|
+
// ponytail: three known ceilings.
|
|
21
|
+
// - It counts one process only. Two replicas each let `max` calls through. The
|
|
22
|
+
// upgrade path is a shared counter (one adopter already keeps one in Redis).
|
|
23
|
+
// - A full gate makes callers wait instead of moving on to the next candidate
|
|
24
|
+
// in a fallback chain. The upgrade is a bounded wait that fails as transient,
|
|
25
|
+
// so the pool tries the next one.
|
|
26
|
+
// - A fallback probe stuck waiting here has no first chunk, so once it goes
|
|
27
|
+
// stale (`probeStaleMs`) the pool can let one extra probe in beside it.
|
|
28
|
+
import type {
|
|
29
|
+
ChatMessage,
|
|
30
|
+
Provider,
|
|
31
|
+
ProviderChunk,
|
|
32
|
+
StreamOptions,
|
|
33
|
+
ToolDefinition,
|
|
34
|
+
} from "./types.ts";
|
|
35
|
+
|
|
36
|
+
export interface Gate {
|
|
37
|
+
/** How many calls may hold a slot at once. */
|
|
38
|
+
readonly max: number;
|
|
39
|
+
/** How many slots are held right now. */
|
|
40
|
+
readonly held: number;
|
|
41
|
+
/** How many calls are queued for a slot. */
|
|
42
|
+
readonly waiting: number;
|
|
43
|
+
/**
|
|
44
|
+
* Wait for a slot, first in, first out. Resolves to its release, which is
|
|
45
|
+
* safe to call more than once. The signal covers the whole life of the slot:
|
|
46
|
+
* an abort while waiting leaves the queue and rejects with `signal.reason`,
|
|
47
|
+
* and an abort while held releases it.
|
|
48
|
+
*/
|
|
49
|
+
acquire(signal?: AbortSignal): Promise<() => void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A gate with `max` slots. Keep one alive per connection, so every call on
|
|
53
|
+
* it shares the count. */
|
|
54
|
+
export function createGate(max: number): Gate {
|
|
55
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
56
|
+
throw new Error(`[providerkit] A gate needs a whole number of slots, 1 or more. Got ${max}.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let held = 0;
|
|
60
|
+
const queue: Array<() => void> = [];
|
|
61
|
+
|
|
62
|
+
function acquire(signal?: AbortSignal): Promise<() => void> {
|
|
63
|
+
// Rejected before it queues: a caller already gone must not take a place
|
|
64
|
+
// in line, or a slot, even for a moment.
|
|
65
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
66
|
+
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
let state: "waiting" | "held" | "released" = "waiting";
|
|
69
|
+
|
|
70
|
+
const release = () => {
|
|
71
|
+
if (state !== "held") return;
|
|
72
|
+
state = "released";
|
|
73
|
+
signal?.removeEventListener("abort", onAbort);
|
|
74
|
+
// Handed straight to the next waiter, so `held` never dips and a
|
|
75
|
+
// newcomer arriving now finds the gate full and queues behind them.
|
|
76
|
+
const next = queue.shift();
|
|
77
|
+
if (next) next();
|
|
78
|
+
else held--;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const grant = () => {
|
|
82
|
+
state = "held";
|
|
83
|
+
resolve(release);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function onAbort() {
|
|
87
|
+
if (state === "held") {
|
|
88
|
+
release();
|
|
89
|
+
} else if (state === "waiting") {
|
|
90
|
+
state = "released";
|
|
91
|
+
queue.splice(queue.indexOf(grant), 1);
|
|
92
|
+
reject(signal?.reason);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (held < max) {
|
|
97
|
+
held++;
|
|
98
|
+
grant();
|
|
99
|
+
} else {
|
|
100
|
+
queue.push(grant);
|
|
101
|
+
}
|
|
102
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
max,
|
|
108
|
+
get held() {
|
|
109
|
+
return held;
|
|
110
|
+
},
|
|
111
|
+
get waiting() {
|
|
112
|
+
return queue.length;
|
|
113
|
+
},
|
|
114
|
+
acquire,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Let a provider run at most `gate.max` calls at once.
|
|
120
|
+
*
|
|
121
|
+
* Put it outside the watchdog. The watchdog arms on its first read, and this
|
|
122
|
+
* reads it only once a slot is held, so a call queued here never spends its
|
|
123
|
+
* deadlines waiting. Inside it, a call that stood in line for five minutes
|
|
124
|
+
* would time out as a silent provider before it ever sent a byte.
|
|
125
|
+
*/
|
|
126
|
+
export function withGate(provider: Provider, gate: Gate): Provider {
|
|
127
|
+
return {
|
|
128
|
+
...provider,
|
|
129
|
+
createStream(
|
|
130
|
+
messages: ChatMessage[],
|
|
131
|
+
tools: ToolDefinition[],
|
|
132
|
+
opts: StreamOptions = {},
|
|
133
|
+
): AsyncIterable<ProviderChunk> {
|
|
134
|
+
// A generator, so nothing runs (the wait included) until the first read.
|
|
135
|
+
async function* gated(): AsyncGenerator<ProviderChunk> {
|
|
136
|
+
const release = await gate.acquire(opts.signal);
|
|
137
|
+
try {
|
|
138
|
+
// The slot can be granted and released by the same abort before this
|
|
139
|
+
// line runs (two calls sharing one signal). Such a call is over, and
|
|
140
|
+
// must not reach the wire without a slot.
|
|
141
|
+
opts.signal?.throwIfAborted();
|
|
142
|
+
yield* provider.createStream(messages, tools, opts);
|
|
143
|
+
} finally {
|
|
144
|
+
// Covers the end, a throw and a consumer's `break`. An abort may
|
|
145
|
+
// have released it already, and a second call is a no-op.
|
|
146
|
+
release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return gated();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export * from "./types.ts";
|
|
2
|
+
export * from "./turn.ts";
|
|
2
3
|
export * from "./errors.ts";
|
|
3
4
|
export * from "./retry.ts";
|
|
4
5
|
export * from "./fallback.ts";
|
|
5
6
|
export * from "./key-pool.ts";
|
|
6
7
|
export * from "./watchdog.ts";
|
|
8
|
+
export * from "./gate.ts";
|
|
7
9
|
export * from "./usage.ts";
|
|
8
10
|
export * from "./transport.ts";
|
|
9
11
|
export * from "./rate-limit.ts";
|
|
@@ -21,3 +23,4 @@ export * from "./providers/responses.ts";
|
|
|
21
23
|
export * from "./providers/gemini.ts";
|
|
22
24
|
export * from "./providers/zai.ts";
|
|
23
25
|
export * from "./providers/factory.ts";
|
|
26
|
+
export * from "./registry.ts";
|
package/src/key-pool.ts
CHANGED
|
@@ -55,12 +55,20 @@ export interface KeyPoolOptions {
|
|
|
55
55
|
now?: () => number;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** No key can serve right now. `retryAtMs` = when the soonest one is back.
|
|
59
|
-
|
|
58
|
+
/** No key can serve right now. `retryAtMs` = when the soonest one is back.
|
|
59
|
+
*
|
|
60
|
+
* A `ProviderError` of kind `rate` that carries that moment as `resetAtMs` and
|
|
61
|
+
* `retryAfterMs`. As a plain `Error` it classified as `rate` only by its
|
|
62
|
+
* wording and carried no deadline anyone read, so a FallbackPool around the
|
|
63
|
+
* key pool benched it for its default minute, whatever the keys had said. */
|
|
64
|
+
export class NoAvailableKeyError extends ProviderError {
|
|
60
65
|
readonly retryAtMs: number;
|
|
61
66
|
|
|
62
|
-
constructor(label: string, retryAtMs: number) {
|
|
63
|
-
super(`No ${label} API key available (all rate-limited or denied)
|
|
67
|
+
constructor(label: string, retryAtMs: number, now: number) {
|
|
68
|
+
super(label, "rate", `No ${label} API key available (all rate-limited or denied)`, {
|
|
69
|
+
resetAtMs: retryAtMs,
|
|
70
|
+
retryAfterMs: Math.max(0, retryAtMs - now),
|
|
71
|
+
});
|
|
64
72
|
this.name = "NoAvailableKeyError";
|
|
65
73
|
this.retryAtMs = retryAtMs;
|
|
66
74
|
}
|
|
@@ -93,6 +101,17 @@ export class KeyPool {
|
|
|
93
101
|
return this.free.length + (this.paid ? 1 : 0);
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/** Each key's tier and when it is back (0 = serving now): free keys in
|
|
105
|
+
* config order, then the paid one. Never the key itself, so a status page
|
|
106
|
+
* can show it as is. */
|
|
107
|
+
status(): Array<{ tier: KeyTier; evictedUntil: number }> {
|
|
108
|
+
const now = this.now();
|
|
109
|
+
return [...this.free, ...(this.paid ? [this.paid] : [])].map(({ tier, evictedUntil }) => ({
|
|
110
|
+
tier,
|
|
111
|
+
evictedUntil: evictedUntil > now ? evictedUntil : 0,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
/**
|
|
97
116
|
* Run `fn` with the next available key, rotating on key-specific and
|
|
98
117
|
* transient failures. Everything else (a bad request, a content block) throws
|
|
@@ -101,7 +120,7 @@ export class KeyPool {
|
|
|
101
120
|
*/
|
|
102
121
|
async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
|
|
103
122
|
const candidates = this.candidates(this.now());
|
|
104
|
-
if (candidates.length === 0) throw
|
|
123
|
+
if (candidates.length === 0) throw this.noneAvailable();
|
|
105
124
|
|
|
106
125
|
let last: unknown;
|
|
107
126
|
for (const key of candidates) {
|
|
@@ -112,7 +131,7 @@ export class KeyPool {
|
|
|
112
131
|
// The single-key rule: with nothing to rotate to, benching the only key
|
|
113
132
|
// answers NoAvailableKeyError to every call for the next minute — for a
|
|
114
133
|
// failure the caller's own retry would have absorbed.
|
|
115
|
-
const cooldown = this.size > 1 ? cooldownFor(err) : null;
|
|
134
|
+
const cooldown = this.size > 1 ? cooldownFor(err, this.now()) : null;
|
|
116
135
|
if (cooldown === null) throw err;
|
|
117
136
|
key.evictedUntil = this.now() + cooldown.forMs;
|
|
118
137
|
this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
|
|
@@ -120,8 +139,7 @@ export class KeyPool {
|
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
// Every candidate was evicted during this call.
|
|
123
|
-
if (this.candidates(this.now()).length === 0)
|
|
124
|
-
throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
|
|
142
|
+
if (this.candidates(this.now()).length === 0) throw this.noneAvailable();
|
|
125
143
|
throw last;
|
|
126
144
|
}
|
|
127
145
|
|
|
@@ -138,9 +156,10 @@ export class KeyPool {
|
|
|
138
156
|
return out;
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
private
|
|
159
|
+
private noneAvailable(): NoAvailableKeyError {
|
|
142
160
|
const all = this.paid ? [...this.free, this.paid] : this.free;
|
|
143
|
-
|
|
161
|
+
const retryAtMs = Math.min(...all.map((key) => key.evictedUntil));
|
|
162
|
+
return new NoAvailableKeyError(this.label, retryAtMs, this.now());
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
|
|
@@ -149,15 +168,24 @@ export class KeyPool {
|
|
|
149
168
|
* key's fault. Nothing is gained by rotating, and benching would spend the
|
|
150
169
|
* pool on a request that fails the same way everywhere.
|
|
151
170
|
*/
|
|
152
|
-
function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
|
|
171
|
+
function cooldownFor(err: unknown, now: number): { kind: ErrorKind; forMs: number } | null {
|
|
153
172
|
if (!(err instanceof ProviderError)) return null;
|
|
154
173
|
const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
|
|
155
174
|
switch (err.kind) {
|
|
156
175
|
// The three kinds that are about THIS key.
|
|
157
176
|
case "rate":
|
|
158
177
|
return { kind: err.kind, forMs: clamp(err.retryAfterMs ?? RATE_COOLDOWN_MS) };
|
|
159
|
-
case "quota":
|
|
160
|
-
|
|
178
|
+
case "quota": {
|
|
179
|
+
// A spent window names its reset, and that outranks Retry-After: Anthropic
|
|
180
|
+
// sends `retry-after: 60` beside a weekly window, and a key benched for
|
|
181
|
+
// the minute is dialled again into the same wall.
|
|
182
|
+
const reset = err.resetAtMs;
|
|
183
|
+
const forMs =
|
|
184
|
+
reset !== undefined && Number.isFinite(reset)
|
|
185
|
+
? reset - now
|
|
186
|
+
: (err.retryAfterMs ?? QUOTA_COOLDOWN_MS);
|
|
187
|
+
return { kind: err.kind, forMs: clamp(forMs) };
|
|
188
|
+
}
|
|
161
189
|
case "auth":
|
|
162
190
|
// 12 h, not forever: a key is also refused while a billing account is
|
|
163
191
|
// reinstated, and a pool that drops keys permanently ends up empty.
|
|
@@ -189,6 +217,10 @@ function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
|
|
|
189
217
|
* committed to one key, exactly as a retry is committed past its first chunk
|
|
190
218
|
* (retry.ts, rule 2): a mid-stream 429 evicts nothing and rotates nothing,
|
|
191
219
|
* because there is no way to resume a half-rendered answer on another key.
|
|
220
|
+
*
|
|
221
|
+
* The factory runs once per attempt. An adapter with a session header mints
|
|
222
|
+
* its id when it is built, so give the factory an `instanceSessionId`, or each
|
|
223
|
+
* attempt opens a new session on a gateway that pins sessions to one host.
|
|
192
224
|
*/
|
|
193
225
|
export function withKeyPool(pool: KeyPool, factory: (apiKey: string) => Provider): Provider {
|
|
194
226
|
// The identity every key shares. Building an adapter performs no I/O — it
|
package/src/presets.ts
CHANGED
|
@@ -373,8 +373,9 @@ export const PROVIDER_PRESETS = {
|
|
|
373
373
|
* Go serves three wires, and `routes` picks one from the model id (cc-proxy's
|
|
374
374
|
* table, from Go's own docs): `minimax-` and `qwen` on Anthropic /messages
|
|
375
375
|
* (key in `x-api-key`), `gpt-`, `grok-` and `muse-spark-` on /responses, and
|
|
376
|
-
* everything else on chat completions. Each model in a
|
|
377
|
-
* own route, so one preset serves a chain that crosses
|
|
376
|
+
* everything else on chat completions, `grok-4.5` included. Each model in a
|
|
377
|
+
* chain resolves its own route, so one preset serves a chain that crosses
|
|
378
|
+
* wires.
|
|
378
379
|
*
|
|
379
380
|
* Muse Contributor, measured 2026-09-28 on /responses: text, images, strict
|
|
380
381
|
* JSON Schema, parallel tools and tool-result round trips all passed. It
|
|
@@ -408,6 +409,15 @@ export const PROVIDER_PRESETS = {
|
|
|
408
409
|
baseUrl: "https://opencode.ai/zen/go",
|
|
409
410
|
auth: "key",
|
|
410
411
|
},
|
|
412
|
+
{
|
|
413
|
+
// The one Grok Go serves on chat completions, while 4.6 and 4.7 are on
|
|
414
|
+
// /responses: cc-proxy's catalog pins it there as an override of the
|
|
415
|
+
// family rule. It sits above the `grok-` route because the first match
|
|
416
|
+
// wins.
|
|
417
|
+
prefixes: ["grok-4.5"],
|
|
418
|
+
shape: "openai",
|
|
419
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
420
|
+
},
|
|
411
421
|
{
|
|
412
422
|
prefixes: ["gpt-", "grok-", "muse-spark-"],
|
|
413
423
|
shape: "responses",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
+
import { repairHistory } from "../context.ts";
|
|
2
3
|
import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
|
|
3
4
|
import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
|
|
4
|
-
import { parseToolArgs } from "../tool-args.ts";
|
|
5
|
-
import { streamSse, apiUrl } from "../transport.ts";
|
|
5
|
+
import { parseToolArgs, settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
|
|
6
|
+
import { streamSse, apiUrl, parseFrame } from "../transport.ts";
|
|
6
7
|
import { attributionHeaders } from "../attribution.ts";
|
|
7
8
|
import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
|
|
8
9
|
import type {
|
|
@@ -54,6 +55,13 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
|
|
|
54
55
|
/** Header that carries `StreamOptions.sessionId`. When set and a call has no
|
|
55
56
|
* sessionId, one stable id for this provider instance rides instead. */
|
|
56
57
|
sessionHeader?: string;
|
|
58
|
+
/** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
|
|
59
|
+
* adapter mints one when it is built. Pass one when something rebuilds the
|
|
60
|
+
* adapter (`withKeyPool` and `withToken` build one per attempt): each build
|
|
61
|
+
* mints a new id, and a gateway that pins a session to one upstream host
|
|
62
|
+
* then treats every call as a new session, re-routes it and misses its
|
|
63
|
+
* prompt cache. Ignored without `sessionHeader`. */
|
|
64
|
+
instanceSessionId?: string;
|
|
57
65
|
/** Say "no thinking" with an explicit `thinking: { type: "disabled" }` when
|
|
58
66
|
* effort resolves to none, instead of omitting the field. For endpoints
|
|
59
67
|
* where an absent field means the model's default, not off. Z.ai's coding
|
|
@@ -191,19 +199,32 @@ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
|
|
|
191
199
|
return false;
|
|
192
200
|
}
|
|
193
201
|
|
|
194
|
-
|
|
202
|
+
/**
|
|
203
|
+
* A stated stop, or a throw for one this adapter does not know. An unknown
|
|
204
|
+
* reason used to end the stream as a clean success with no finish at all.
|
|
205
|
+
* Nothing says the answer ended on purpose, so it is `overload`: theirs, and
|
|
206
|
+
* worth a retry (cc-proxy answers it the same way). `pause_turn` lands there
|
|
207
|
+
* too: it is a server-tool turn waiting to be sent back, which this adapter
|
|
208
|
+
* never asks for, and a half answer is not a finished one.
|
|
209
|
+
*/
|
|
210
|
+
function mapStopReason(provider: string, reason: string): FinishReason {
|
|
195
211
|
switch (reason) {
|
|
196
212
|
case "end_turn":
|
|
197
213
|
case "stop_sequence":
|
|
198
214
|
return "stop";
|
|
199
215
|
case "tool_use":
|
|
200
216
|
return "tool_calls";
|
|
217
|
+
// `model_context_window_exceeded`: the context window filled before the
|
|
218
|
+
// answer did. Cut short, and a retry fills it the same way.
|
|
201
219
|
case "max_tokens":
|
|
220
|
+
case "model_context_window_exceeded":
|
|
202
221
|
return "length";
|
|
203
222
|
case "refusal":
|
|
204
223
|
return "content_filter";
|
|
205
224
|
default:
|
|
206
|
-
|
|
225
|
+
throw new ProviderError(provider, "overload", `${provider} stopped the turn on "${reason}"`, {
|
|
226
|
+
code: reason,
|
|
227
|
+
});
|
|
207
228
|
}
|
|
208
229
|
}
|
|
209
230
|
|
|
@@ -270,6 +291,34 @@ function jsonBlock(json: JsonOutput): unknown {
|
|
|
270
291
|
return { type: "text", text: schemaPrompt(json.schema) };
|
|
271
292
|
}
|
|
272
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Tool ids as this wire accepts them: `[A-Za-z0-9_-]` only. Kimi's
|
|
296
|
+
* `functions.search:0` is a 400 here, and an opencode-go rotation hands a
|
|
297
|
+
* history from chat completions to Messages mid-conversation, so a Kimi id
|
|
298
|
+
* does arrive. Each other character becomes `_`, and a result follows its call
|
|
299
|
+
* because both go through the same map.
|
|
300
|
+
*
|
|
301
|
+
* Collision-safe in one forward pass: an id whose cleaned form is already
|
|
302
|
+
* taken gets `_2`, `_3`…, judged only against ids seen before it, so an earlier
|
|
303
|
+
* id never changes because of a later one and the prompt cache keeps its
|
|
304
|
+
* prefix. A history whose ids are already clean comes out byte for byte.
|
|
305
|
+
*/
|
|
306
|
+
function anthropicIds(): (id: string) => string {
|
|
307
|
+
const sent = new Map<string, string>();
|
|
308
|
+
const taken = new Set<string>();
|
|
309
|
+
return (id) => {
|
|
310
|
+
let wire = sent.get(id);
|
|
311
|
+
if (wire === undefined) {
|
|
312
|
+
const clean = id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
313
|
+
wire = clean;
|
|
314
|
+
for (let k = 2; taken.has(wire); k++) wire = `${clean}_${k}`;
|
|
315
|
+
sent.set(id, wire);
|
|
316
|
+
taken.add(wire);
|
|
317
|
+
}
|
|
318
|
+
return wire;
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
273
322
|
export function toAnthropicMessages(
|
|
274
323
|
messages: readonly ChatMessage[],
|
|
275
324
|
provider = "anthropic",
|
|
@@ -277,6 +326,7 @@ export function toAnthropicMessages(
|
|
|
277
326
|
system?: unknown[];
|
|
278
327
|
messages: unknown[];
|
|
279
328
|
} {
|
|
329
|
+
const wireId = anthropicIds();
|
|
280
330
|
const system = messages
|
|
281
331
|
.filter((m) => m.role === "system")
|
|
282
332
|
.map((m) => m.content)
|
|
@@ -299,7 +349,7 @@ export function toAnthropicMessages(
|
|
|
299
349
|
pushBlocks("user", [
|
|
300
350
|
{
|
|
301
351
|
type: "tool_result",
|
|
302
|
-
tool_use_id: message.toolCallId,
|
|
352
|
+
tool_use_id: wireId(message.toolCallId),
|
|
303
353
|
content: [
|
|
304
354
|
{ type: "text", text: message.content },
|
|
305
355
|
...(message.images ?? []).map((image) => ({
|
|
@@ -319,7 +369,7 @@ export function toAnthropicMessages(
|
|
|
319
369
|
for (const call of message.toolCalls ?? []) {
|
|
320
370
|
blocks.push({
|
|
321
371
|
type: "tool_use",
|
|
322
|
-
id: call.id,
|
|
372
|
+
id: wireId(call.id),
|
|
323
373
|
name: call.name,
|
|
324
374
|
input: parseToolArgs(call.arguments),
|
|
325
375
|
});
|
|
@@ -359,7 +409,9 @@ interface AnthropicEvent {
|
|
|
359
409
|
export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
360
410
|
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
361
411
|
const id = config.id ?? "anthropic";
|
|
362
|
-
const instanceSessionId = config.sessionHeader
|
|
412
|
+
const instanceSessionId = config.sessionHeader
|
|
413
|
+
? (config.instanceSessionId ?? crypto.randomUUID())
|
|
414
|
+
: undefined;
|
|
363
415
|
|
|
364
416
|
const provider: Provider = {
|
|
365
417
|
id,
|
|
@@ -373,7 +425,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
373
425
|
const model = opts.model ?? config.model;
|
|
374
426
|
const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
375
427
|
const effort = opts.effort ?? config.effort;
|
|
376
|
-
|
|
428
|
+
// Repaired first: a result with no call, a call with no result, or an id
|
|
429
|
+
// used twice is a 400 that loses the turn. See repairHistory.
|
|
430
|
+
const sent = repairHistory(messages).messages;
|
|
431
|
+
const { system, messages: body } = toAnthropicMessages(sent, id);
|
|
377
432
|
|
|
378
433
|
// Anthropic's own `service_tier` means auto or standard_only, not this.
|
|
379
434
|
if (opts.serviceTier) {
|
|
@@ -438,7 +493,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
438
493
|
// ponytail: every loop step after the first goes unthought on these
|
|
439
494
|
// models. The upgrade is replaying the signed thinking blocks.
|
|
440
495
|
const unthought =
|
|
441
|
-
!claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(
|
|
496
|
+
!claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(sent));
|
|
442
497
|
if (claude) {
|
|
443
498
|
// An effort the caller never set sends nothing: the model keeps its own
|
|
444
499
|
// default, which on most of these is thinking ON.
|
|
@@ -476,12 +531,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
476
531
|
let cachedInputTokens = 0;
|
|
477
532
|
let cacheWriteTokens = 0;
|
|
478
533
|
let outputTokens = 0;
|
|
479
|
-
let toolCall:
|
|
534
|
+
let toolCall: ToolCallIdentity | null = null;
|
|
535
|
+
let sawToolCall = false;
|
|
480
536
|
let blockIndex = -1;
|
|
481
537
|
// What proves the turn ended rather than the socket: a stated stop
|
|
482
538
|
// reason, or `message_stop`. See the check after the loop.
|
|
483
539
|
let stopped = false;
|
|
484
|
-
let closed = false;
|
|
485
540
|
const usageChunk = (): ProviderChunk => ({
|
|
486
541
|
type: "usage",
|
|
487
542
|
usage: {
|
|
@@ -510,13 +565,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
510
565
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
511
566
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
512
567
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
568
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
569
|
+
// The stop reason rides in `message_delta` with the final usage, so a
|
|
570
|
+
// read that dies after it loses nothing but `message_stop`.
|
|
571
|
+
finished: () => stopped,
|
|
513
572
|
})) {
|
|
514
|
-
|
|
515
|
-
try {
|
|
516
|
-
event = JSON.parse(data) as AnthropicEvent;
|
|
517
|
-
} catch {
|
|
518
|
-
continue; // a keep-alive or a frame we do not model
|
|
519
|
-
}
|
|
573
|
+
const event = parseFrame<AnthropicEvent>(data, id);
|
|
520
574
|
|
|
521
575
|
switch (event.type) {
|
|
522
576
|
// A failure the backend reports after its headers went out. Classified
|
|
@@ -542,6 +596,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
542
596
|
id: event.content_block.id ?? "",
|
|
543
597
|
name: event.content_block.name ?? "",
|
|
544
598
|
};
|
|
599
|
+
// Checked here, because this block is the only place this shape
|
|
600
|
+
// states a call's id and name.
|
|
601
|
+
settleToolCalls(id, [toolCall]);
|
|
602
|
+
sawToolCall = true;
|
|
545
603
|
yield {
|
|
546
604
|
type: "delta",
|
|
547
605
|
toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
|
|
@@ -577,21 +635,31 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
577
635
|
cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
|
|
578
636
|
cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
|
|
579
637
|
outputTokens = event.usage?.output_tokens ?? outputTokens;
|
|
580
|
-
if (event.delta?.stop_reason)
|
|
581
|
-
|
|
582
|
-
|
|
638
|
+
if (event.delta?.stop_reason) {
|
|
639
|
+
const stated = mapStopReason(id, event.delta.stop_reason);
|
|
640
|
+
stopped = true;
|
|
641
|
+
// Anthropic-compatible gateways can end a tool turn on
|
|
642
|
+
// `end_turn`, and a caller reading that as done never runs the
|
|
643
|
+
// tool. Only `stop` is promoted: a length cut may be what broke
|
|
644
|
+
// the call.
|
|
645
|
+
const finishReason = stated === "stop" && sawToolCall ? "tool_calls" : stated;
|
|
646
|
+
yield { type: "finish", finishReason };
|
|
647
|
+
}
|
|
583
648
|
break;
|
|
584
649
|
}
|
|
585
650
|
|
|
651
|
+
// The turn's last event, so the reader stops here. Some gateways
|
|
652
|
+
// append a `[DONE]` or other bytes after it, and a read that died on
|
|
653
|
+
// those failed an answer that was already whole (Kimi, GLM and
|
|
654
|
+
// OpenCode Go, measured by cc-proxy).
|
|
586
655
|
case "message_stop":
|
|
587
|
-
closed = true;
|
|
588
656
|
yield usageChunk();
|
|
589
|
-
|
|
657
|
+
return;
|
|
590
658
|
}
|
|
591
659
|
}
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
//
|
|
660
|
+
// A gateway that states its stop reason and then skips `message_stop`, or
|
|
661
|
+
// whose socket dies after the reason, still finished the turn, and still
|
|
662
|
+
// billed it.
|
|
595
663
|
if (stopped) {
|
|
596
664
|
yield usageChunk();
|
|
597
665
|
return;
|
package/src/providers/factory.ts
CHANGED
|
@@ -46,6 +46,17 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
|
|
|
46
46
|
/** Per-request headers (request ids, `ChatGPT-Account-Id`) on top of the
|
|
47
47
|
* preset's static protocol headers. */
|
|
48
48
|
headers?: Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* The session id for a call without `StreamOptions.sessionId`, on a preset
|
|
51
|
+
* that sends one (`opencode-go`, `chatgpt`). Absent, the provider mints its
|
|
52
|
+
* own when it is built. Pass one when you build the provider per attempt
|
|
53
|
+
* (`withKeyPool`, `withToken`): each build would mint a new id, and a gateway
|
|
54
|
+
* that pins a session to one upstream host then treats every call as a new
|
|
55
|
+
* session, re-routes it and misses its prompt cache. Every model in a chain
|
|
56
|
+
* sends the same one. A preset without a session header has nowhere to put
|
|
57
|
+
* it and ignores it.
|
|
58
|
+
*/
|
|
59
|
+
instanceSessionId?: string;
|
|
49
60
|
/**
|
|
50
61
|
* App attribution for OpenRouter's rankings. The site URL rides as
|
|
51
62
|
* `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent on
|
|
@@ -69,15 +80,15 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
|
|
|
69
80
|
fetchImpl?: typeof fetch;
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/** What one adapter for one model takes: a `PresetProviderConfig` without the
|
|
84
|
+
* chain (`models`) and without the layers that go around an adapter. */
|
|
85
|
+
export type PresetAdapterConfig = Omit<
|
|
86
|
+
PresetProviderConfig,
|
|
87
|
+
"models" | keyof ProviderFallbackConfig
|
|
88
|
+
>;
|
|
89
|
+
|
|
72
90
|
export function createPresetProvider(id: ProviderPresetId, config: PresetProviderConfig): Provider {
|
|
73
|
-
|
|
74
|
-
// string — validate rather than index into undefined.
|
|
75
|
-
if (!(id in PROVIDER_PRESETS)) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
`[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
const preset: ProviderPreset = PROVIDER_PRESETS[id];
|
|
91
|
+
const preset = presetFor(id);
|
|
81
92
|
const chain = config.models?.length ? config.models : config.model ? undefined : preset.rotation;
|
|
82
93
|
if (chain && chain.length > 1) {
|
|
83
94
|
const { models: _models, fallbacks = [], ...single } = config;
|
|
@@ -90,7 +101,27 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
|
|
|
90
101
|
],
|
|
91
102
|
});
|
|
92
103
|
}
|
|
93
|
-
const
|
|
104
|
+
const { models: _models, ...single } = config;
|
|
105
|
+
return withConfiguredFallbacks(
|
|
106
|
+
presetAdapter(id, { ...single, model: chain?.[0] ?? config.model }),
|
|
107
|
+
config,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The bare adapter for one preset and one model: the wire that serves the
|
|
113
|
+
* model, its auth style and the preset's quirks, and nothing around them. No
|
|
114
|
+
* fallbacks, no watchdog, no `models` chain. A registry builds one per
|
|
115
|
+
* (connection, model) and puts those layers around all of them once; calling
|
|
116
|
+
* `createPresetProvider` per model would nest a second pool and a second
|
|
117
|
+
* watchdog inside its own.
|
|
118
|
+
*
|
|
119
|
+
* The adapter's `id` is the preset id, because the OpenAI shape reads its
|
|
120
|
+
* effort dialect from it (`opencode-go`, `openrouter`, `deepseek`).
|
|
121
|
+
*/
|
|
122
|
+
export function presetAdapter(id: ProviderPresetId, config: PresetAdapterConfig): Provider {
|
|
123
|
+
const preset = presetFor(id);
|
|
124
|
+
const model = config.model ?? preset.defaultModel;
|
|
94
125
|
if (!model) {
|
|
95
126
|
throw new Error(`[providerkit] Preset "${id}" has no defaultModel. Pass a model.`);
|
|
96
127
|
}
|
|
@@ -104,14 +135,16 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
|
|
|
104
135
|
const headers = { ...preset.headers, ...config.headers };
|
|
105
136
|
const baseUrl = config.baseUrl ?? wire.baseUrl;
|
|
106
137
|
const maxTokens = config.maxTokens ?? preset.maxTokens;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
// Every adapter builds its own pool and watchdog from these. The type leaves
|
|
139
|
+
// them out, but a wider object (a whole `PresetProviderConfig` in a variable)
|
|
140
|
+
// can still carry them, and the adapter would nest a second pool and a
|
|
141
|
+
// second watchdog inside the caller's. Cleared, it stays bare.
|
|
142
|
+
const baseConfig = {
|
|
143
|
+
...config,
|
|
144
|
+
fallbacks: undefined,
|
|
145
|
+
fallbackOptions: undefined,
|
|
146
|
+
watchdog: undefined,
|
|
147
|
+
};
|
|
115
148
|
|
|
116
149
|
let provider: Provider;
|
|
117
150
|
switch (wire.shape) {
|
|
@@ -184,10 +217,22 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
|
|
|
184
217
|
break;
|
|
185
218
|
}
|
|
186
219
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
220
|
+
// Here rather than in `createPresetProvider`: the wire is resolved here, and
|
|
221
|
+
// the bare adapter is exported, so it must refuse on its own a per-call model
|
|
222
|
+
// it would post to the wrong endpoint. That 404 reads as an unknown model,
|
|
223
|
+
// and a pool benches the model for an hour.
|
|
224
|
+
return preset.routes ? refuseOtherWires(provider, preset, wire) : provider;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The table is closed (`satisfies`), but a caller may hold an id as a plain
|
|
228
|
+
* string. Validate rather than index into undefined. */
|
|
229
|
+
function presetFor(id: ProviderPresetId): ProviderPreset {
|
|
230
|
+
if (!(id in PROVIDER_PRESETS)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return PROVIDER_PRESETS[id];
|
|
191
236
|
}
|
|
192
237
|
|
|
193
238
|
/** The endpoint a model is served from: its route when the preset has one for
|