dsh-plugin-subscription-oauth 0.6.0 → 0.7.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.
@@ -24,13 +24,38 @@ export interface CcSwitchBalance {
24
24
  /** Whether the account reports itself as usable. */
25
25
  available: boolean;
26
26
  }
27
+ /**
28
+ * Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
29
+ *
30
+ * huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
31
+ * the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
32
+ * `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
33
+ * the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
34
+ * resolve to the same `{base}/v1/usage`.
35
+ *
36
+ * @param input - the provider's API key and base URL.
37
+ * @returns the balance, or undefined when the provider did not answer with one.
38
+ */
39
+ export declare function queryUsageBalance(input: {
40
+ apiKey: string;
41
+ baseUrl: string;
42
+ fetchFn?: FetchFn;
43
+ }): Promise<CcSwitchBalance | undefined>;
27
44
  /**
28
45
  * Query one provider's balance the way cc-switch does.
29
46
  *
30
- * @param input - the provider's API key, and optionally a fetch to substitute in tests.
47
+ * cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
48
+ * and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
49
+ * path is effectively hardcoded there; for a provider the user added through a custom
50
+ * endpoint (like huanling), the same path is read against that provider's own base URL,
51
+ * which is the only place its balance can live.
52
+ *
53
+ * @param input - the provider's API key and base URL.
31
54
  * @returns the balance, or undefined when the provider did not answer with one.
32
55
  */
33
56
  export declare function queryCcSwitchBalance(input: {
34
57
  apiKey: string;
58
+ /** The provider's own base URL; `https://api.deepseek.com` for the official one. */
59
+ baseUrl: string;
35
60
  fetchFn?: FetchFn;
36
61
  }): Promise<CcSwitchBalance | undefined>;
@@ -23,18 +23,75 @@ function num(value) {
23
23
  return value.trim();
24
24
  return undefined;
25
25
  }
26
+ /**
27
+ * Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
28
+ *
29
+ * huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
30
+ * the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
31
+ * `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
32
+ * the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
33
+ * resolve to the same `{base}/v1/usage`.
34
+ *
35
+ * @param input - the provider's API key and base URL.
36
+ * @returns the balance, or undefined when the provider did not answer with one.
37
+ */
38
+ export async function queryUsageBalance(input) {
39
+ const fetchFn = input.fetchFn ?? proxiedFetch;
40
+ const base = input.baseUrl.replace(/\/+$/, '').replace(/\/v1$/i, '');
41
+ const url = `${base}/v1/usage`;
42
+ let response;
43
+ try {
44
+ response = await fetchFn(url, {
45
+ headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
46
+ signal: AbortSignal.timeout(15_000),
47
+ });
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ if (!response.ok)
53
+ return undefined;
54
+ let payload;
55
+ try {
56
+ payload = await response.json();
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ if (typeof payload !== 'object' || payload === null)
62
+ return undefined;
63
+ const record = payload;
64
+ const total = num(record.remaining);
65
+ if (total === undefined)
66
+ return undefined;
67
+ const currency = typeof record.unit === 'string' && record.unit.trim() !== '' ? record.unit.trim() : 'USD';
68
+ return {
69
+ currency,
70
+ total,
71
+ available: record.isValid !== false && record.is_active !== false,
72
+ };
73
+ }
26
74
  /**
27
75
  * Query one provider's balance the way cc-switch does.
28
76
  *
29
- * @param input - the provider's API key, and optionally a fetch to substitute in tests.
77
+ * cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
78
+ * and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
79
+ * path is effectively hardcoded there; for a provider the user added through a custom
80
+ * endpoint (like huanling), the same path is read against that provider's own base URL,
81
+ * which is the only place its balance can live.
82
+ *
83
+ * @param input - the provider's API key and base URL.
30
84
  * @returns the balance, or undefined when the provider did not answer with one.
31
85
  */
32
86
  export async function queryCcSwitchBalance(input) {
33
87
  const fetchFn = input.fetchFn ?? proxiedFetch;
88
+ // Resolve `<base>/user/balance` without doubling or dropping the path: a base that
89
+ // already ends in `/v1` keeps it, since that is the path the OpenAI-compatible wire
90
+ // uses and the balance endpoint lives beside it.
91
+ const url = `${input.baseUrl.replace(/\/+$/, '')}/user/balance`;
34
92
  let response;
35
93
  try {
36
- // cc-switch's exact request shape: hardcoded URL, bearer auth, 15 s timeout.
37
- response = await fetchFn('https://api.deepseek.com/user/balance', {
94
+ response = await fetchFn(url, {
38
95
  headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
39
96
  signal: AbortSignal.timeout(15_000),
40
97
  });
package/lib/auth/rpc.d.ts CHANGED
@@ -16,6 +16,8 @@ export type CredentialResolver = (ref: string) => Promise<string | undefined>;
16
16
  export interface BalanceContext {
17
17
  /** The credential reference each provider id's balance query uses. */
18
18
  readonly credentialRefs: Readonly<Record<string, string>>;
19
+ /** The base URL each provider id's balance query reads, when it is not DeepSeek's own. */
20
+ readonly baseUrls: Readonly<Record<string, string>>;
19
21
  /** Resolve one credential reference to its key. */
20
22
  readonly resolveCredential?: CredentialResolver;
21
23
  /**
package/lib/auth/rpc.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BAD_REQUEST, ProviderError } from '../errors.js';
2
- import { queryCcSwitchBalance } from './ccswitch.js';
2
+ import { queryCcSwitchBalance, queryUsageBalance } from './ccswitch.js';
3
3
  import { readDeepSeekBalance } from './deepseek-balance.js';
4
4
  import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
5
5
  /** Wrap a value as a successful envelope result. */
@@ -165,11 +165,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
165
165
  return await readDeepSeekBalance(balance?.resolveDeepSeekAccount?.(), client);
166
166
  }
167
167
  case 'ccswitch-balance': {
168
- // A non-subscription provider's balance, by cc-switch's query. The caller names
169
- // the provider; the configured map turns that into the credential reference the
170
- // provider's profile uses, and the host's credential service resolves it. No
171
- // entry, no key, or a key the provider does not answer for means no balance —
172
- // never a fabricated zero.
168
+ // A non-subscription provider's balance, by cc-switch's query against the provider's
169
+ // own base URL. The caller names the provider; the configured map turns that into
170
+ // the credential reference the provider's profile uses and the base URL that profile
171
+ // connects to, and the host's credential service resolves the reference. No entry,
172
+ // no key, or a key the provider does not answer for means no balance — never a
173
+ // fabricated zero.
173
174
  const provider = requiredProvider(payload, endpoint);
174
175
  const credentialRef = balance?.credentialRefs[provider];
175
176
  if (credentialRef === undefined || balance?.resolveCredential === undefined)
@@ -177,7 +178,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
177
178
  const apiKey = await balance.resolveCredential(credentialRef);
178
179
  if (apiKey === undefined)
179
180
  return undefined;
180
- return await queryCcSwitchBalance({ apiKey });
181
+ const baseUrl = balance?.baseUrls[provider] ?? 'https://api.deepseek.com';
182
+ // A provider that answers the OpenAI-compatible `/v1/usage` (the shape cc-switch's
183
+ // custom template reads, `{ remaining, unit }`) is asked that first; one that
184
+ // answers the DeepSeek `balance_infos` shape is asked that as a fallback.
185
+ return await queryUsageBalance({ apiKey, baseUrl })
186
+ ?? await queryCcSwitchBalance({ apiKey, baseUrl });
181
187
  }
182
188
  case 'reload':
183
189
  return registry.reload();
package/lib/index.d.ts CHANGED
@@ -45,16 +45,22 @@ export interface Config {
45
45
  */
46
46
  auto?: boolean;
47
47
  /**
48
- * The credential reference a non-subscription provider's balance query uses, by
49
- * provider id.
48
+ * The balance source a non-subscription provider uses, by provider id.
50
49
  *
51
50
  * A provider that is not a subscription this plugin serves has no usage windows to
52
51
  * meter, so the chip shows its balance instead — read by cc-switch's query, which needs
53
- * the provider's API key. The provider's profile names that key by reference
54
- * (`apiKeyEnv`), and the host's credential service resolves the name; this map is the
55
- * one place a provider id is tied to that reference. A provider with no entry, or
56
- * whose key does not resolve, gets no balance and the chip stays silent.
52
+ * the provider's API key and its own base URL (the balance endpoint lives beside the
53
+ * provider's OpenAI-compatible wire, not on DeepSeek's). Each entry names the
54
+ * credential reference the provider's profile uses (`apiKeyEnv`) and the base URL that
55
+ * profile connects to; the host's credential service resolves the reference. A provider
56
+ * with no entry, or whose key does not resolve, gets no balance and the chip stays
57
+ * silent.
57
58
  */
59
+ balanceSources?: Record<string, {
60
+ credentialRef: string;
61
+ baseUrl: string;
62
+ }>;
63
+ /** @deprecated Use {@link balanceSources}, which carries the base URL too. */
58
64
  balanceCredentialRefs?: Record<string, string>;
59
65
  }
60
66
  /** Configuration schema, with the config file's own defaults applied. */
package/lib/index.js CHANGED
@@ -42,6 +42,8 @@ export const Config = z.object({
42
42
  defaultModel: z.string().default(KIMI_DEFAULT_MODEL),
43
43
  extraModels: z.array(z.string()).default([]),
44
44
  auto: z.boolean().default(true),
45
+ balanceSources: z.dict(z.object({ credentialRef: z.string(), baseUrl: z.string() })).default({}),
46
+ balanceCredentialRefs: z.dict(z.string()).default({}),
45
47
  });
46
48
  /**
47
49
  * Register every configured provider route, its credential store, and — when
@@ -188,13 +190,18 @@ export function apply(ctx, config = {}) {
188
190
  handles.set(route.id, handle);
189
191
  ctx.effect(() => () => handle(), `${name}: ${route.id} provider route`);
190
192
  }
191
- // A non-subscription provider's balance is read by cc-switch's query, which needs the
192
- // provider's API key. The provider's profile names that key by reference (`apiKeyEnv`),
193
- // and the configured map ties a provider id to that reference; the host's credential
194
- // service resolves it. The map lives in server config because that is where the rest
195
- // of the plugin's wiring is, and the client learns a provider's balance by id, never
196
- // by its credential reference.
197
- const credentialRefs = config.balanceCredentialRefs ?? {};
193
+ // A non-subscription provider's balance is read by cc-switch's query against the
194
+ // provider's own base URL (the balance endpoint lives beside its OpenAI-compatible
195
+ // wire, not on DeepSeek's), which needs the provider's API key. Each configured source
196
+ // names the credential reference the provider's profile uses (`apiKeyEnv`) and the base
197
+ // URL that profile connects to; the host's credential service resolves the reference.
198
+ const balanceSources = config.balanceSources ?? {};
199
+ const credentialRefs = { ...config.balanceCredentialRefs };
200
+ const baseUrls = {};
201
+ for (const [provider, source] of Object.entries(balanceSources)) {
202
+ credentialRefs[provider] = source.credentialRef;
203
+ baseUrls[provider] = source.baseUrl;
204
+ }
198
205
  // The DeepSeek account's balance is read on the node half through the host's
199
206
  // `deepseekAccount` service; the browser reaches it through the plugin's own RPC, the
200
207
  // same channel as the rest of the chip's data. The service is read lazily, per request
@@ -202,6 +209,7 @@ export function apply(ctx, config = {}) {
202
209
  // without it still serves every other endpoint, and the balance endpoint answers nothing.
203
210
  const balanceContext = {
204
211
  credentialRefs,
212
+ baseUrls,
205
213
  resolveDeepSeekAccount: () => {
206
214
  try {
207
215
  return ctx.get('deepseekAccount');
@@ -213,7 +221,20 @@ export function apply(ctx, config = {}) {
213
221
  resolveCredential: async (ref) => {
214
222
  try {
215
223
  const credentials = ctx.get('credentials');
216
- return (await credentials?.resolve?.(ref))?.value;
224
+ const hit = await credentials?.resolve?.(ref);
225
+ // The resolve answer is `{ value, source }`; an env-var source may name the var
226
+ // rather than carry the value, so both are considered before giving up.
227
+ if (typeof hit === 'object' && hit !== null) {
228
+ const record = hit;
229
+ if (typeof record.value === 'string' && record.value.trim() !== '')
230
+ return record.value;
231
+ if (typeof record.source === 'string') {
232
+ const fromEnv = process.env[record.source];
233
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '')
234
+ return fromEnv;
235
+ }
236
+ }
237
+ return undefined;
217
238
  }
218
239
  catch {
219
240
  return undefined;
@@ -95,7 +95,15 @@ export declare class KimiAdapter extends LlmAdapter {
95
95
  private discovered;
96
96
  /** Ids the user added by hand, cached so the sync catalog can name them. */
97
97
  private userAddedIds;
98
+ /** Tools already reported as flattened, so each is stated once. */
99
+ private readonly flattenedTools;
98
100
  constructor(options: KimiAdapterOptions);
101
+ /**
102
+ * Tool schemas in the shape Moonshot accepts: no `$ref`, and under its size
103
+ * ceiling. A declaration too large to send keeps its arguments, and that is
104
+ * reported once per tool rather than on every turn.
105
+ */
106
+ private moonshotTools;
99
107
  providerInfo(provider: string): LlmProviderInfo;
100
108
  providerRetryPolicy(): undefined;
101
109
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
@@ -14,7 +14,7 @@ import { idleWatchdog, httpLlmError, isMissingOrInvalidCredential, mapFetchFailu
14
14
  import { streamProviderResponses } from './responses-stream.js';
15
15
  import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
16
16
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
17
- import { moonshotToolParameters } from '../translate/moonshot-schema.js';
17
+ import { prepareMoonshotTool } from '../translate/moonshot-schema.js';
18
18
  /**
19
19
  * Coerce one decoded credential entry into a Kimi session.
20
20
  * @param value - decoded JSON value for one account.
@@ -131,10 +131,31 @@ export class KimiAdapter extends LlmAdapter {
131
131
  discovered;
132
132
  /** Ids the user added by hand, cached so the sync catalog can name them. */
133
133
  userAddedIds = [];
134
+ /** Tools already reported as flattened, so each is stated once. */
135
+ flattenedTools = new Set();
134
136
  constructor(options) {
135
137
  super();
136
138
  this.options = options;
137
139
  }
140
+ /**
141
+ * Tool schemas in the shape Moonshot accepts: no `$ref`, and under its size
142
+ * ceiling. A declaration too large to send keeps its arguments, and that is
143
+ * reported once per tool rather than on every turn.
144
+ */
145
+ moonshotTools(tools) {
146
+ return tools.map((tool) => {
147
+ const prepared = prepareMoonshotTool(tool.parameters);
148
+ if (prepared.preparedAs !== 'as-declared' && !this.flattenedTools.has(tool.name)) {
149
+ this.flattenedTools.add(tool.name);
150
+ this.options.onWarn?.(prepared.preparedAs === 'flattened'
151
+ ? `kimi: ${tool.name} declares an argument schema larger than the endpoint accepts; `
152
+ + 'its arguments are described without their nested structure'
153
+ : `kimi: ${tool.name} declares an argument schema larger than the endpoint accepts; `
154
+ + 'it is offered as an unrestricted object');
155
+ }
156
+ return { ...tool, parameters: prepared.parameters };
157
+ });
158
+ }
138
159
  providerInfo(provider) {
139
160
  return { id: provider, name: KIMI_PROVIDER_NAME };
140
161
  }
@@ -311,7 +332,11 @@ export class KimiAdapter extends LlmAdapter {
311
332
  input: resolved.input,
312
333
  ...options.tools === undefined || options.tools.length === 0
313
334
  ? {}
314
- : { tools: toResponsesTools(options.tools, moonshotToolParameters), tool_choice: 'auto', parallel_tool_calls: true },
335
+ : {
336
+ tools: toResponsesTools(this.moonshotTools(options.tools)),
337
+ tool_choice: 'auto',
338
+ parallel_tool_calls: true,
339
+ },
315
340
  ...options.maxTokens === undefined ? {} : { max_output_tokens: options.maxTokens },
316
341
  ...options.reasoningEffort === undefined
317
342
  ? {}
@@ -1,31 +1,54 @@
1
1
  /**
2
2
  * Tool-schema preparation for the Moonshot wire (Kimi Code).
3
3
  *
4
- * Moonshot validates `tools[].function.parameters` with its own JSON-Schema
5
- * reader, and that reader cannot settle a `$ref` that carries sibling
6
- * keywords: instead of intersecting the two, it re-expands the node, so the
7
- * whole request is refused with
8
- * `detected infinite recursion without termination condition`. The harness
9
- * forwards tool schemas exactly as their owner declared them, and the MCP
10
- * tools REA registers use that shape heavily —
11
- * `{"$ref": "#/$defs/__schema12", "description": "..."}` — 153 of 170 tools
12
- * carried at least one such node, and the first one Moonshot reached killed
13
- * every Kimi request that included a tool.
4
+ * Moonshot validates `tools[].function.parameters` with its own reader, and
5
+ * that reader expands every `$ref` before it measures or checks the schema. The
6
+ * harness forwards each tool's schema exactly as its owner declared it, and
7
+ * every MCP tool REA registers holds its shapes in a `$defs` block that its
8
+ * properties reference, so Kimi refused the whole request with
9
+ * `tools.function.parameters is not a valid moonshot flavored json schema`.
14
10
  *
15
- * A `$ref` that stands alone is accepted, and so is keeping `$defs` for it, so
16
- * only the offending nodes are rewritten: the referenced schema is inlined
17
- * under the node's own keywords (JSON Schema 2020-12 applies both, and the
18
- * node's own keywords win on a conflict), which leaves the wire schema with
19
- * standalone references only. Resolution is bounded by the reference stack, so
20
- * a real cycle ends by dropping the `$ref` and keeping the sibling keywords
21
- * rather than recursing.
11
+ * Live probes against `api.kimi.com` measured both edges of that reader:
12
+ *
13
+ * - a `$ref` whose target itself holds `$ref`s is refused as
14
+ * `detected infinite recursion without termination condition` — the failing
15
+ * tool reported the node `properties.actions` and, once that node was
16
+ * inlined so its reference stood alone, the deeper `properties.actions.items`.
17
+ * A one-level reference is accepted; a chain is not.
18
+ * - the expanded schema has a size ceiling between 80,081 B (accepted) and
19
+ * 124,389 B (refused). `compare_web_captures` declares 57,175 B of shared
20
+ * definitions but expands past 153,000 B, so it was refused even as declared.
21
+ *
22
+ * So this module sends no references at all — every local pointer is replaced
23
+ * by the definition it names, and the document keywords that only existed to
24
+ * hold them (`$defs`, `$schema`, `$id`, `definitions`) are dropped. A pointer
25
+ * that re-enters itself cannot be inlined, and there the referencing node keeps
26
+ * its own keywords instead of recursing. A tool whose expanded schema still
27
+ * exceeds the ceiling is flattened in steps — the arguments with their prose,
28
+ * then the arguments alone, then an unrestricted object — so that one oversized
29
+ * tool costs its own detail rather than the whole request.
22
30
  *
23
31
  * @module dsh-plugin-subscription-oauth/translate/moonshot-schema
24
32
  */
33
+ /** How much of a declaration a schema too large to send had to give up. */
34
+ export type MoonshotPreparation =
35
+ /** Sent as declared, with its references inlined. */
36
+ 'as-declared'
37
+ /** Only the arguments: names, types, choices, `required`, and prose while it fits. */
38
+ | 'flattened'
39
+ /** An unrestricted object, because even the arguments did not fit. */
40
+ | 'permissive';
41
+ /** One prepared tool schema together with what had to be done to it. */
42
+ export interface MoonshotToolSchema {
43
+ /** The schema to send. */
44
+ parameters: Record<string, unknown>;
45
+ /** What the declaration had to give up to fit. */
46
+ preparedAs: MoonshotPreparation;
47
+ }
25
48
  /**
26
49
  * Prepare one tool's parameters for Moonshot.
27
50
  *
28
51
  * @param parameters - the tool's declared argument schema.
29
- * @returns a detached schema whose `$ref` nodes carry no sibling keywords.
52
+ * @returns the schema to send and what it had to give up to fit the endpoint.
30
53
  */
31
- export declare function moonshotToolParameters(parameters: Record<string, unknown>): Record<string, unknown>;
54
+ export declare function prepareMoonshotTool(parameters: Record<string, unknown>): MoonshotToolSchema;
@@ -1,27 +1,47 @@
1
1
  /**
2
2
  * Tool-schema preparation for the Moonshot wire (Kimi Code).
3
3
  *
4
- * Moonshot validates `tools[].function.parameters` with its own JSON-Schema
5
- * reader, and that reader cannot settle a `$ref` that carries sibling
6
- * keywords: instead of intersecting the two, it re-expands the node, so the
7
- * whole request is refused with
8
- * `detected infinite recursion without termination condition`. The harness
9
- * forwards tool schemas exactly as their owner declared them, and the MCP
10
- * tools REA registers use that shape heavily —
11
- * `{"$ref": "#/$defs/__schema12", "description": "..."}` — 153 of 170 tools
12
- * carried at least one such node, and the first one Moonshot reached killed
13
- * every Kimi request that included a tool.
4
+ * Moonshot validates `tools[].function.parameters` with its own reader, and
5
+ * that reader expands every `$ref` before it measures or checks the schema. The
6
+ * harness forwards each tool's schema exactly as its owner declared it, and
7
+ * every MCP tool REA registers holds its shapes in a `$defs` block that its
8
+ * properties reference, so Kimi refused the whole request with
9
+ * `tools.function.parameters is not a valid moonshot flavored json schema`.
14
10
  *
15
- * A `$ref` that stands alone is accepted, and so is keeping `$defs` for it, so
16
- * only the offending nodes are rewritten: the referenced schema is inlined
17
- * under the node's own keywords (JSON Schema 2020-12 applies both, and the
18
- * node's own keywords win on a conflict), which leaves the wire schema with
19
- * standalone references only. Resolution is bounded by the reference stack, so
20
- * a real cycle ends by dropping the `$ref` and keeping the sibling keywords
21
- * rather than recursing.
11
+ * Live probes against `api.kimi.com` measured both edges of that reader:
12
+ *
13
+ * - a `$ref` whose target itself holds `$ref`s is refused as
14
+ * `detected infinite recursion without termination condition` — the failing
15
+ * tool reported the node `properties.actions` and, once that node was
16
+ * inlined so its reference stood alone, the deeper `properties.actions.items`.
17
+ * A one-level reference is accepted; a chain is not.
18
+ * - the expanded schema has a size ceiling between 80,081 B (accepted) and
19
+ * 124,389 B (refused). `compare_web_captures` declares 57,175 B of shared
20
+ * definitions but expands past 153,000 B, so it was refused even as declared.
21
+ *
22
+ * So this module sends no references at all — every local pointer is replaced
23
+ * by the definition it names, and the document keywords that only existed to
24
+ * hold them (`$defs`, `$schema`, `$id`, `definitions`) are dropped. A pointer
25
+ * that re-enters itself cannot be inlined, and there the referencing node keeps
26
+ * its own keywords instead of recursing. A tool whose expanded schema still
27
+ * exceeds the ceiling is flattened in steps — the arguments with their prose,
28
+ * then the arguments alone, then an unrestricted object — so that one oversized
29
+ * tool costs its own detail rather than the whole request.
22
30
  *
23
31
  * @module dsh-plugin-subscription-oauth/translate/moonshot-schema
24
32
  */
33
+ /** Keywords that describe the schema document rather than the values it accepts. */
34
+ const DOCUMENT_KEYS = new Set(['$schema', '$id', '$defs', 'definitions']);
35
+ /** Root keywords a flattened schema keeps. */
36
+ const ROOT_KEYS = ['type', 'description', 'required', 'additionalProperties', 'minProperties', 'maxProperties'];
37
+ /**
38
+ * Largest expanded schema the endpoint is asked to take, in bytes.
39
+ *
40
+ * Measured rather than assumed: 80,081 B was accepted and 124,389 B was refused
41
+ * with `At path 'root': schema exceeds maximum allowed size`. This sits below
42
+ * both so a schema this plugin sends has room to spare.
43
+ */
44
+ const SIZE_BUDGET = 64 * 1024;
25
45
  /** True for a plain object usable as a schema node. */
26
46
  function object(value) {
27
47
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -43,7 +63,7 @@ function pointed(root, ref) {
43
63
  return node;
44
64
  }
45
65
  /**
46
- * Rewrite one node so every `$ref` left on the wire stands alone.
66
+ * Replace every reference reachable from one node with what it names.
47
67
  *
48
68
  * @param node - the node to rewrite.
49
69
  * @param root - the tool's root schema, which local pointers resolve against.
@@ -56,26 +76,67 @@ function inline(node, root, refs) {
56
76
  if (!object(node))
57
77
  return node;
58
78
  const { $ref, ...siblings } = node;
59
- if (typeof $ref !== 'string') {
60
- return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, inline(value, root, refs)]));
79
+ if (typeof $ref === 'string') {
80
+ // A repeated pointer, or one that names nothing local, cannot be inlined:
81
+ // the node's own keywords are all that is left.
82
+ const target = refs.has($ref) ? undefined : pointed(root, $ref);
83
+ return object(target)
84
+ ? inline({ ...target, ...siblings }, root, new Set([...refs, $ref]))
85
+ : inline(siblings, root, refs);
61
86
  }
62
- // A standalone reference is exactly what Moonshot expects, so it stays as is
63
- // and keeps its `$defs` entry.
64
- if (Object.keys(siblings).length === 0)
65
- return { $ref };
66
- // A repeated pointer, or one that is not a local object, cannot be inlined:
67
- // the sibling keywords are all that is left to terminate on.
68
- const target = refs.has($ref) ? undefined : pointed(root, $ref);
69
- return object(target)
70
- ? inline({ ...target, ...siblings }, root, new Set([...refs, $ref]))
71
- : inline(siblings, root, refs);
87
+ return Object.fromEntries(Object.entries(node)
88
+ .filter(([key]) => !DOCUMENT_KEYS.has(key))
89
+ .map(([key, value]) => [key, inline(value, root, refs)]));
90
+ }
91
+ /**
92
+ * Keep only what a model needs to call the tool: the root's own keywords, and
93
+ * each argument's name, type, value choices and — while the budget allows —
94
+ * its description.
95
+ *
96
+ * @param schema - an already inlined schema.
97
+ * @param prose - whether descriptions are worth their bytes.
98
+ * @returns the flattened schema.
99
+ */
100
+ function flatten(schema, prose) {
101
+ const out = {};
102
+ for (const key of ROOT_KEYS) {
103
+ if (Object.hasOwn(schema, key) && (prose || key !== 'description'))
104
+ out[key] = schema[key];
105
+ }
106
+ if (!object(schema.properties))
107
+ return out;
108
+ out.properties = Object.fromEntries(Object.entries(schema.properties).map(([name, property]) => {
109
+ const node = object(property) ? property : {};
110
+ const reduced = {};
111
+ if (typeof node.type === 'string')
112
+ reduced.type = node.type;
113
+ if (Array.isArray(node.enum))
114
+ reduced.enum = node.enum;
115
+ if (prose && typeof node.description === 'string')
116
+ reduced.description = node.description;
117
+ return [name, reduced];
118
+ }));
119
+ return out;
120
+ }
121
+ /** Bytes the endpoint measures once it has expanded every reference. */
122
+ function expandedBytes(schema) {
123
+ return Buffer.byteLength(JSON.stringify(schema));
72
124
  }
73
125
  /**
74
126
  * Prepare one tool's parameters for Moonshot.
75
127
  *
76
128
  * @param parameters - the tool's declared argument schema.
77
- * @returns a detached schema whose `$ref` nodes carry no sibling keywords.
129
+ * @returns the schema to send and what it had to give up to fit the endpoint.
78
130
  */
79
- export function moonshotToolParameters(parameters) {
80
- return inline(parameters, parameters, new Set());
131
+ export function prepareMoonshotTool(parameters) {
132
+ const inlined = inline(parameters, parameters, new Set());
133
+ if (expandedBytes(inlined) <= SIZE_BUDGET)
134
+ return { parameters: inlined, preparedAs: 'as-declared' };
135
+ const documented = flatten(inlined, true);
136
+ if (expandedBytes(documented) <= SIZE_BUDGET)
137
+ return { parameters: documented, preparedAs: 'flattened' };
138
+ const bare = flatten(inlined, false);
139
+ if (expandedBytes(bare) <= SIZE_BUDGET)
140
+ return { parameters: bare, preparedAs: 'flattened' };
141
+ return { parameters: { type: 'object', additionalProperties: true }, preparedAs: 'permissive' };
81
142
  }
@@ -53,11 +53,9 @@ export declare function toResponsesInput(messages: readonly RequestMessage[], sy
53
53
  * than letting the endpoint normalize every schema into strict mode.
54
54
  *
55
55
  * @param tools - the request's tool schemas.
56
- * @param prepare - an endpoint-specific rewrite of one parameters schema, for
57
- * a route whose JSON-Schema reader refuses the schema as declared.
58
56
  * @returns `tools` array entries.
59
57
  */
60
- export declare function toResponsesTools(tools: readonly ToolSchema[], prepare?: (parameters: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown>[];
58
+ export declare function toResponsesTools(tools: readonly ToolSchema[]): Record<string, unknown>[];
61
59
  /** The `usage` object of a completed response. */
62
60
  export interface ResponsesUsage {
63
61
  input_tokens?: number;
@@ -124,16 +124,14 @@ export async function toResponsesInput(messages, system, resolveImage) {
124
124
  * than letting the endpoint normalize every schema into strict mode.
125
125
  *
126
126
  * @param tools - the request's tool schemas.
127
- * @param prepare - an endpoint-specific rewrite of one parameters schema, for
128
- * a route whose JSON-Schema reader refuses the schema as declared.
129
127
  * @returns `tools` array entries.
130
128
  */
131
- export function toResponsesTools(tools, prepare) {
129
+ export function toResponsesTools(tools) {
132
130
  return tools.map((tool) => ({
133
131
  type: 'function',
134
132
  name: tool.name,
135
133
  description: tool.description,
136
- parameters: prepare === undefined ? tool.parameters : prepare(tool.parameters),
134
+ parameters: tool.parameters,
137
135
  strict: false,
138
136
  }));
139
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",