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 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): RepeatedToolCallDetection | null;
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.23",
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.23",
42
- "jeopi-utils": "16.2.23",
43
- "jeopi-wire": "16.2.23",
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
 
@@ -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): RepeatedToolCallDetection | null {
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 !== this.#threshold) return null;
99
- return {
100
- kind: "repeated_tool_call",
101
- toolName: toolCall.name,
102
- count: this.#count,
103
- resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
104
- argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
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
  }