jeopi-ai 16.2.23 → 16.2.25
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 +13 -0
- package/dist/types/registry/api-key-login.d.ts +4 -0
- package/dist/types/registry/api-key-validation.d.ts +4 -0
- package/dist/types/registry/registry.d.ts +4 -0
- package/dist/types/registry/tencent.d.ts +7 -0
- package/dist/types/utils/tool-call-loop-guard.d.ts +12 -2
- package/package.json +4 -4
- package/src/registry/api-key-login.ts +5 -0
- package/src/registry/api-key-validation.ts +5 -1
- package/src/registry/registry.ts +2 -0
- package/src/registry/tencent.ts +33 -0
- package/src/utils/tool-call-loop-guard.ts +51 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.25] - 2026-07-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the `tencent` provider registry def (`registry/tencent.ts`): API-key login validated against Tencent Cloud MaaS's Anthropic Messages endpoint (`tokenhub-intl.tencentcloudmaas.com/v1/messages`), registered in the `ALL` provider list. Env fallback: `TENCENT_API_KEY`. Verified live: full inference round-trip (GLM-5.2, Hunyuan MT2 Plus) succeeds end-to-end through `streamAnthropic`; DeepSeek/Kimi ids on the same test key correctly surfaced a clean `402` (quota exhausted) rather than an error.
|
|
10
|
+
- `createApiKeyLogin`'s `anthropic-messages` validation now accepts an optional `acceptableErrorStatuses` list: HTTP statuses that still prove the key is valid (e.g. a billing/quota gate only a real, authenticated key would reach) and should pass validation instead of being treated as an invalid key. Discovered live while validating the `tencent` login: a real, correctly-authenticated key with no active billing got HTTP 402, which the login flow was previously rejecting as "invalid key" — `tencent`'s validation now sets `acceptableErrorStatuses: [402]`; other providers are unaffected (opt-in, defaults to none).
|
|
11
|
+
|
|
12
|
+
## [16.2.24] - 2026-07-03
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `ToolCallLoopGuard.recordTurn` now also detects an alternating A,B,A,B,… tool-call cycle (exactly two distinct calls filling a 6-turn window), returning a `cyclical_tool_calls` detection alongside the existing `repeated_tool_call` one. `recordTurn`'s return type is now `ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection`.
|
|
17
|
+
|
|
5
18
|
## [16.2.21] - 2026-07-02
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -17,6 +17,10 @@ type AnthropicMessagesValidation = {
|
|
|
17
17
|
provider: string;
|
|
18
18
|
baseUrl: string;
|
|
19
19
|
model: string;
|
|
20
|
+
/** HTTP error statuses that still prove the key is valid (e.g. a billing/quota
|
|
21
|
+
* gate that only a real, recognized key would reach) and should be treated as
|
|
22
|
+
* successful validation rather than an invalid-key failure. */
|
|
23
|
+
acceptableErrorStatuses?: readonly number[];
|
|
20
24
|
};
|
|
21
25
|
type ModelsEndpointValidation = {
|
|
22
26
|
kind: "models-endpoint";
|
|
@@ -14,6 +14,10 @@ type AnthropicCompatibleValidationOptions = {
|
|
|
14
14
|
model: string;
|
|
15
15
|
signal?: AbortSignal;
|
|
16
16
|
fetch?: FetchImpl;
|
|
17
|
+
/** HTTP error statuses that still prove the key is valid (e.g. a billing/quota
|
|
18
|
+
* gate that only a real, recognized key would reach) and should be treated as
|
|
19
|
+
* successful validation rather than an invalid-key failure. */
|
|
20
|
+
acceptableErrorStatuses?: readonly number[];
|
|
17
21
|
};
|
|
18
22
|
type ModelListValidationOptions = {
|
|
19
23
|
provider: string;
|
|
@@ -232,6 +232,10 @@ declare const ALL: ({
|
|
|
232
232
|
readonly name: "Tavily";
|
|
233
233
|
readonly envKeys: "TAVILY_API_KEY";
|
|
234
234
|
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
235
|
+
} | {
|
|
236
|
+
readonly id: "tencent";
|
|
237
|
+
readonly name: "Tencent Cloud MaaS";
|
|
238
|
+
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
235
239
|
} | {
|
|
236
240
|
readonly id: "together";
|
|
237
241
|
readonly name: "Together";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginTencent: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const tencentProvider: {
|
|
4
|
+
readonly id: "tencent";
|
|
5
|
+
readonly name: "Tencent Cloud MaaS";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -3,6 +3,8 @@ import type { AssistantMessage, ToolResultMessage } from "../types";
|
|
|
3
3
|
export interface ToolCallLoopGuardOptions {
|
|
4
4
|
readonly threshold: number;
|
|
5
5
|
readonly exemptTools: readonly string[];
|
|
6
|
+
/** Window size for alternating-pattern (A,B,A,B,…) cycle detection; defaults to 6. */
|
|
7
|
+
readonly cycleWindowSize?: number;
|
|
6
8
|
}
|
|
7
9
|
/** A completed assistant turn plus the tool results it produced. */
|
|
8
10
|
export interface ToolCallLoopTurn {
|
|
@@ -17,10 +19,18 @@ export interface RepeatedToolCallDetection {
|
|
|
17
19
|
readonly resultSummary: string;
|
|
18
20
|
readonly argumentsSummary: string;
|
|
19
21
|
}
|
|
22
|
+
/** Details needed to steer the model away from an alternating tool-call cycle (A,B,A,B,…). */
|
|
23
|
+
export interface CyclicalToolCallDetection {
|
|
24
|
+
readonly kind: "cyclical_tool_calls";
|
|
25
|
+
/** Distinct tool names seen in the detection window, in first-seen order. */
|
|
26
|
+
readonly toolNames: readonly string[];
|
|
27
|
+
readonly windowSize: number;
|
|
28
|
+
}
|
|
29
|
+
export type ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection;
|
|
20
30
|
/** Detects consecutive identical assistant tool calls across model turns. */
|
|
21
31
|
export declare class ToolCallLoopGuard {
|
|
22
32
|
#private;
|
|
23
33
|
constructor(options: ToolCallLoopGuardOptions);
|
|
24
|
-
/** Records one completed turn and returns the threshold hit, if any. */
|
|
25
|
-
recordTurn(turn: ToolCallLoopTurn):
|
|
34
|
+
/** Records one completed turn and returns the threshold/cycle hit, if any. */
|
|
35
|
+
recordTurn(turn: ToolCallLoopTurn): ToolCallLoopDetection | null;
|
|
26
36
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "jeopi-ai",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.25",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/akillness/jeopi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"jeopi-catalog": "16.2.
|
|
42
|
-
"jeopi-utils": "16.2.
|
|
43
|
-
"jeopi-wire": "16.2.
|
|
41
|
+
"jeopi-catalog": "16.2.25",
|
|
42
|
+
"jeopi-utils": "16.2.25",
|
|
43
|
+
"jeopi-wire": "16.2.25",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -26,6 +26,10 @@ type AnthropicMessagesValidation = {
|
|
|
26
26
|
provider: string;
|
|
27
27
|
baseUrl: string;
|
|
28
28
|
model: string;
|
|
29
|
+
/** HTTP error statuses that still prove the key is valid (e.g. a billing/quota
|
|
30
|
+
* gate that only a real, recognized key would reach) and should be treated as
|
|
31
|
+
* successful validation rather than an invalid-key failure. */
|
|
32
|
+
acceptableErrorStatuses?: readonly number[];
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
type ModelsEndpointValidation = {
|
|
@@ -92,6 +96,7 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
|
|
|
92
96
|
apiKey: trimmed,
|
|
93
97
|
baseUrl: config.validation.baseUrl,
|
|
94
98
|
model: config.validation.model,
|
|
99
|
+
acceptableErrorStatuses: config.validation.acceptableErrorStatuses,
|
|
95
100
|
signal: options.signal,
|
|
96
101
|
fetch: options.fetch,
|
|
97
102
|
});
|
|
@@ -16,6 +16,10 @@ type AnthropicCompatibleValidationOptions = {
|
|
|
16
16
|
model: string;
|
|
17
17
|
signal?: AbortSignal;
|
|
18
18
|
fetch?: FetchImpl;
|
|
19
|
+
/** HTTP error statuses that still prove the key is valid (e.g. a billing/quota
|
|
20
|
+
* gate that only a real, recognized key would reach) and should be treated as
|
|
21
|
+
* successful validation rather than an invalid-key failure. */
|
|
22
|
+
acceptableErrorStatuses?: readonly number[];
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
type ModelListValidationOptions = {
|
|
@@ -106,7 +110,7 @@ export async function validateAnthropicCompatibleApiKey(options: AnthropicCompat
|
|
|
106
110
|
signal,
|
|
107
111
|
});
|
|
108
112
|
|
|
109
|
-
if (response.ok) {
|
|
113
|
+
if (response.ok || options.acceptableErrorStatuses?.includes(response.status)) {
|
|
110
114
|
return;
|
|
111
115
|
}
|
|
112
116
|
|
package/src/registry/registry.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { qwenPortalProvider } from "./qwen-portal";
|
|
|
49
49
|
import { sakanaProvider } from "./sakana";
|
|
50
50
|
import { syntheticProvider } from "./synthetic";
|
|
51
51
|
import { tavilyProvider } from "./tavily";
|
|
52
|
+
import { tencentProvider } from "./tencent";
|
|
52
53
|
import { togetherProvider } from "./together";
|
|
53
54
|
import type { ProviderDefinition } from "./types";
|
|
54
55
|
import { umansProvider } from "./umans";
|
|
@@ -139,6 +140,7 @@ const ALL = [
|
|
|
139
140
|
mistralProvider,
|
|
140
141
|
minimaxProvider,
|
|
141
142
|
amazonBedrockProvider,
|
|
143
|
+
tencentProvider,
|
|
142
144
|
];
|
|
143
145
|
|
|
144
146
|
export type RegistryDef = (typeof ALL)[number];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
3
|
+
import type { ProviderDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
const AUTH_URL = "https://cloud.tencent.com/product/lke";
|
|
6
|
+
const API_BASE_URL = "https://tokenhub-intl.tencentcloudmaas.com";
|
|
7
|
+
const VALIDATION_MODEL = "deepseek-v4-pro";
|
|
8
|
+
|
|
9
|
+
export const loginTencent = createApiKeyLogin({
|
|
10
|
+
providerLabel: "Tencent Cloud MaaS",
|
|
11
|
+
authUrl: AUTH_URL,
|
|
12
|
+
instructions: "Create or copy your Tencent Cloud MaaS API key from the console.",
|
|
13
|
+
promptMessage: "Paste your Tencent Cloud MaaS API key",
|
|
14
|
+
placeholder: "sk-...",
|
|
15
|
+
validation: {
|
|
16
|
+
kind: "anthropic-messages",
|
|
17
|
+
provider: "Tencent Cloud MaaS",
|
|
18
|
+
baseUrl: API_BASE_URL,
|
|
19
|
+
model: VALIDATION_MODEL,
|
|
20
|
+
// Verified live (2026-07): a recognized-but-unbilled key gets HTTP 402
|
|
21
|
+
// ("free trial quota exhausted ... postpaid billing not enabled"), not a
|
|
22
|
+
// 401/403 — the gateway only returns 402 after successfully authenticating
|
|
23
|
+
// the key. Treat it as a valid credential so a real key isn't rejected as
|
|
24
|
+
// "invalid" just because the account has no active billing yet.
|
|
25
|
+
acceptableErrorStatuses: [402],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const tencentProvider = {
|
|
30
|
+
id: "tencent",
|
|
31
|
+
name: "Tencent Cloud MaaS",
|
|
32
|
+
login: (cb: OAuthLoginCallbacks) => loginTencent(cb),
|
|
33
|
+
} as const satisfies ProviderDefinition;
|
|
@@ -9,6 +9,8 @@ const ARGUMENT_SUMMARY_LIMIT = 400;
|
|
|
9
9
|
export interface ToolCallLoopGuardOptions {
|
|
10
10
|
readonly threshold: number;
|
|
11
11
|
readonly exemptTools: readonly string[];
|
|
12
|
+
/** Window size for alternating-pattern (A,B,A,B,…) cycle detection; defaults to 6. */
|
|
13
|
+
readonly cycleWindowSize?: number;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/** A completed assistant turn plus the tool results it produced. */
|
|
@@ -26,6 +28,16 @@ export interface RepeatedToolCallDetection {
|
|
|
26
28
|
readonly argumentsSummary: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** Details needed to steer the model away from an alternating tool-call cycle (A,B,A,B,…). */
|
|
32
|
+
export interface CyclicalToolCallDetection {
|
|
33
|
+
readonly kind: "cyclical_tool_calls";
|
|
34
|
+
/** Distinct tool names seen in the detection window, in first-seen order. */
|
|
35
|
+
readonly toolNames: readonly string[];
|
|
36
|
+
readonly windowSize: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection;
|
|
40
|
+
|
|
29
41
|
function canonicalizeToolCallValue(value: unknown): unknown {
|
|
30
42
|
if (Array.isArray(value)) {
|
|
31
43
|
return value.map(item => canonicalizeToolCallValue(item));
|
|
@@ -70,18 +82,24 @@ export class ToolCallLoopGuard {
|
|
|
70
82
|
#exemptTools: ReadonlySet<string>;
|
|
71
83
|
#lastHash: string | undefined;
|
|
72
84
|
#count = 0;
|
|
85
|
+
#cycleWindowSize: number;
|
|
86
|
+
#recentCalls: Array<{ hash: string; toolName: string }> = [];
|
|
87
|
+
#lastCycleSignature: string | undefined;
|
|
73
88
|
|
|
74
89
|
constructor(options: ToolCallLoopGuardOptions) {
|
|
75
90
|
this.#threshold = Math.max(1, Math.trunc(options.threshold));
|
|
76
91
|
this.#exemptTools = new Set(options.exemptTools);
|
|
92
|
+
this.#cycleWindowSize = Math.max(3, Math.trunc(options.cycleWindowSize ?? 6));
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
/** Records one completed turn and returns the threshold hit, if any. */
|
|
80
|
-
recordTurn(turn: ToolCallLoopTurn):
|
|
95
|
+
/** Records one completed turn and returns the threshold/cycle hit, if any. */
|
|
96
|
+
recordTurn(turn: ToolCallLoopTurn): ToolCallLoopDetection | null {
|
|
81
97
|
const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
|
|
82
98
|
if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) {
|
|
83
99
|
this.#lastHash = undefined;
|
|
84
100
|
this.#count = 0;
|
|
101
|
+
this.#recentCalls = [];
|
|
102
|
+
this.#lastCycleSignature = undefined;
|
|
85
103
|
return null;
|
|
86
104
|
}
|
|
87
105
|
|
|
@@ -95,13 +113,36 @@ export class ToolCallLoopGuard {
|
|
|
95
113
|
this.#count = 1;
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
if (this.#count
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
if (this.#count === this.#threshold) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "repeated_tool_call",
|
|
119
|
+
toolName: toolCall.name,
|
|
120
|
+
count: this.#count,
|
|
121
|
+
resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
|
|
122
|
+
argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Alternating A,B,A,B,… pattern: exact-repeat detection above never fires
|
|
127
|
+
// (the hash changes every turn), but the model is still spinning between
|
|
128
|
+
// exactly two distinct calls without making progress.
|
|
129
|
+
this.#recentCalls.push({ hash, toolName: toolCall.name });
|
|
130
|
+
if (this.#recentCalls.length > this.#cycleWindowSize) this.#recentCalls.shift();
|
|
131
|
+
if (this.#recentCalls.length < this.#cycleWindowSize) return null;
|
|
132
|
+
|
|
133
|
+
const distinctHashes = new Set(this.#recentCalls.map(call => call.hash));
|
|
134
|
+
if (distinctHashes.size !== 2) {
|
|
135
|
+
this.#lastCycleSignature = undefined;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const signature = [...distinctHashes].sort().join("|");
|
|
139
|
+
if (signature === this.#lastCycleSignature) return null;
|
|
140
|
+
this.#lastCycleSignature = signature;
|
|
141
|
+
|
|
142
|
+
const toolNames: string[] = [];
|
|
143
|
+
for (const call of this.#recentCalls) {
|
|
144
|
+
if (!toolNames.includes(call.toolName)) toolNames.push(call.toolName);
|
|
145
|
+
}
|
|
146
|
+
return { kind: "cyclical_tool_calls", toolNames, windowSize: this.#cycleWindowSize };
|
|
106
147
|
}
|
|
107
148
|
}
|