cortico 0.1.1 → 0.1.2

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.
Files changed (85) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +4 -2
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +18 -2
  14. package/src/core/util.ts +15 -0
  15. package/src/deploy.ts +5 -4
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -7
  23. package/src/providers/base.ts +2 -0
  24. package/src/providers/console/config.ts +21 -0
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +11 -19
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +38 -0
  30. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  31. package/src/providers/llamacpp/console/server.ts +23 -2
  32. package/src/providers/llamacpp/index.ts +5 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/options.ts +2 -0
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +33 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +40 -12
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +12 -3
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
  50. package/src/web/client/console-pages/host.ts +22 -5
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/extensions/index.ts +276 -41
  54. package/src/web/client/features/extensions/strings.ts +84 -10
  55. package/src/web/client/features/feature.ts +1 -1
  56. package/src/web/client/features/live/diagnostics.ts +32 -0
  57. package/src/web/client/features/live/index.ts +27 -11
  58. package/src/web/client/features/live/protocol.ts +1 -0
  59. package/src/web/client/features/live/strings.ts +9 -3
  60. package/src/web/client/features/providers/detail.ts +262 -0
  61. package/src/web/client/features/providers/drafts.ts +23 -0
  62. package/src/web/client/features/providers/index.ts +173 -87
  63. package/src/web/client/features/providers/strings.ts +44 -17
  64. package/src/web/client/features/providers/types.ts +15 -0
  65. package/src/web/client/features/settings/general.ts +12 -0
  66. package/src/web/client/features/settings/strings.ts +6 -0
  67. package/src/web/client/main.ts +4 -0
  68. package/src/web/client/shell/index.ts +1 -1
  69. package/src/web/client/ui/icons.ts +9 -1
  70. package/src/web/client/ui/prompt-input.tsx +17 -5
  71. package/src/web/client/ui/strings.ts +0 -2
  72. package/src/web/diagnostics.ts +133 -0
  73. package/src/web/public/login.html +67 -0
  74. package/src/web/public/styles.css +122 -18
  75. package/src/web/server.ts +227 -21
  76. package/src/web/shared/client-panel.ts +3 -0
  77. package/src/web/shared/console-protocol.ts +3 -1
  78. package/src/worlds/bilibili/README.md +1 -1
  79. package/src/worlds/bilibili/overlay/server.ts +4 -2
  80. package/src/worlds/minecraft/ADAPT.md +66 -0
  81. package/src/worlds/minecraft/README.md +8 -0
  82. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  83. package/src/worlds/qq/normalize.ts +14 -0
  84. package/src/worlds/qq/world.ts +53 -7
  85. package/src/worlds/terminal/world.ts +3 -4
@@ -2,23 +2,26 @@
2
2
  * Chat Completions against llama-server. Thinking is the template's `enable_thinking` switch
3
3
  * sent per request through `chat_template_kwargs`; templates without that variable ignore it.
4
4
  * The server returns the chain of thought as `reasoning_content` (`--reasoning-format deepseek`),
5
- * which the shared Chat assembly already maps; past thinking is not replayed.
5
+ * which the shared Chat assembly already maps; history sends it back on every request.
6
+ * `keepPastThinking` off blanks every turn but the synthetic opening; thinking off sends none.
6
7
  */
7
8
  import type { NativeChatMessage } from '../transport/native-types.ts';
8
9
  import type { ModelSpec, ToolSchema, Logger } from '../../core/types.ts';
9
10
  import { nullLogger } from '../../core/util.ts';
10
11
  import { OpenAIHttpClient } from '../transport/chat.ts';
11
- import { mapTools, renderMessagesWithMedia, type CompatMediaOptions } from '../transport/history.ts';
12
+ import { dropPastThinking, mapTools, renderMessagesWithMedia, type CompatMediaOptions } from '../transport/history.ts';
12
13
 
13
14
  export function buildLlamaCppRequestBody(
14
15
  spec: ModelSpec,
15
16
  messages: NativeChatMessage[],
16
17
  tools?: ToolSchema[],
17
18
  media?: CompatMediaOptions,
19
+ opts?: { keepThinking?: boolean },
18
20
  ): Record<string, unknown> {
21
+ const history = opts?.keepThinking === false ? dropPastThinking(messages) : messages;
19
22
  const body: Record<string, unknown> = {
20
23
  model: spec.model,
21
- messages: renderMessagesWithMedia(messages, media),
24
+ messages: renderMessagesWithMedia(history, media, { keepReasoning: spec.thinking }),
22
25
  chat_template_kwargs: { enable_thinking: spec.thinking },
23
26
  };
24
27
  if (spec.thinking && spec.reasoningEffort) body.reasoning_effort = spec.reasoningEffort;
@@ -32,15 +35,17 @@ export function buildLlamaCppRequestBody(
32
35
  export class LlamaCppProvider extends OpenAIHttpClient {
33
36
  private readonly apiKey?: string;
34
37
  private readonly media?: CompatMediaOptions;
38
+ private readonly keepThinking?: () => boolean;
35
39
 
36
- constructor(opts: { baseUrl: string; apiKey?: string; log?: Logger; media?: CompatMediaOptions }) {
40
+ constructor(opts: { baseUrl: string; apiKey?: string; log?: Logger; media?: CompatMediaOptions; keepThinking?: () => boolean }) {
37
41
  super(opts.baseUrl, opts.log ?? nullLogger());
38
42
  this.apiKey = opts.apiKey;
39
43
  this.media = opts.media;
44
+ this.keepThinking = opts.keepThinking;
40
45
  }
41
46
 
42
47
  protected buildBody(spec: ModelSpec, messages: NativeChatMessage[], tools?: ToolSchema[]): Record<string, unknown> {
43
- return buildLlamaCppRequestBody(spec, messages, tools, this.media);
48
+ return buildLlamaCppRequestBody(spec, messages, tools, this.media, { keepThinking: this.keepThinking?.() });
44
49
  }
45
50
 
46
51
  protected headers(): Record<string, string> {
@@ -42,6 +42,8 @@ export function normalizeLlamaCpp(entry: LLMProviderEntry): LLMProviderEntry {
42
42
  if (options.runtime && typeof options.runtime === 'object') {
43
43
  const runtime = options.runtime as Partial<RuntimeOptions>;
44
44
  options.runtime = { ...runtime, release: runtime.release ?? PINNED_RELEASE, backend: runtime.backend ?? defaultBackend() };
45
+ if (typeof options.runtime.release === 'string') options.runtime.release = options.runtime.release.trim();
46
+ if (typeof options.runtime.runtimeDir === 'string') options.runtime.runtimeDir = options.runtime.runtimeDir.trim();
45
47
  if (options.runtime.runtimeDir === '') delete options.runtime.runtimeDir;
46
48
  options.launch = { ...LAUNCH_DEFAULTS, ...(options.launch ?? {}) };
47
49
  }
@@ -0,0 +1,8 @@
1
+ /** New connection names are portable directory names; unchanged historical keys are exempt. */
2
+ export function validateProviderName(name: string): string | null {
3
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name))
4
+ return 'Use English letters, digits, - or _; start with a letter or digit. Spaces are not allowed.';
5
+ if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(name))
6
+ return 'This name is reserved by Windows.';
7
+ return null;
8
+ }
@@ -0,0 +1,33 @@
1
+ import type { ConfigGroup } from '../../core/config-schema.ts';
2
+ import type { LLMProviderEntry } from '../../core/types.ts';
3
+ import type { Language } from '../../core/language.ts';
4
+ import { REASONING_REPLAYS, SYNTHETIC_REASONING_TEXT } from '../transport/responses-input.ts';
5
+ import { text } from './strings.ts';
6
+
7
+ export function protocolConfig(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup[] {
8
+ const S = text(language);
9
+ return [{
10
+ id: `llm.${entry.kind}.${name}.protocol`,
11
+ owner: `provider:${entry.kind}`,
12
+ schema: {
13
+ type: 'object', title: name,
14
+ properties: {
15
+ [`providers.${name}.options.extraHeaders`]: {
16
+ type: 'object', title: language === 'zh' ? '附加请求头(JSON object)' : 'Extra headers (JSON object)',
17
+ },
18
+ [`providers.${name}.options.extraBody`]: {
19
+ type: 'object', title: language === 'zh' ? '附加请求体(JSON object)' : 'Extra request body (JSON object)',
20
+ },
21
+ [`providers.${name}.options.endpointPath`]: {
22
+ type: 'string', title: S.endpointPath, description: S.endpointPathDescription, 'x-hot': true,
23
+ },
24
+ [`providers.${name}.options.reasoningReplay`]: {
25
+ type: 'string', enum: [...REASONING_REPLAYS], title: S.reasoningReplay, description: S.reasoningReplayDescription, 'x-hot': true,
26
+ },
27
+ [`providers.${name}.options.syntheticReasoningText`]: {
28
+ type: 'string', title: S.syntheticReasoningText, description: S.syntheticReasoningTextDescription(SYNTHETIC_REASONING_TEXT), 'x-hot': true,
29
+ },
30
+ },
31
+ },
32
+ }];
33
+ }
@@ -0,0 +1,5 @@
1
+ import type { ConsoleClientBundle } from '../../../web/shared/client-panel.ts';
2
+ import { reasoningPanel } from './reasoning-panel.ts';
3
+
4
+ // The endpoint table (`builtin: 'llm-settings'`) is the console's own; only the reasoning section ships here.
5
+ export default { panels: { reasoning: reasoningPanel } } satisfies ConsoleClientBundle;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Reasoning section of one endpoint: the replay form, written on change, and the probe that
3
+ * determines it. The endpoint comes from `ctx.scope.instance`.
4
+ */
5
+ import type { ConsolePanel, ConsolePanelContext } from '../../../web/shared/client-panel.ts';
6
+ import { configField } from '../../../web/client/features/config/view.ts';
7
+ import type { DetectResult, ProbeOutcome, ReasoningPanelState } from './server.ts';
8
+ import { panel } from '../strings.ts';
9
+
10
+ export const reasoningPanel: ConsolePanel = {
11
+ mount: async (ctx: ConsolePanelContext) => {
12
+ const { ui, root } = ctx;
13
+ const S = ctx.language === 'en' ? panel.en : panel.zh;
14
+ const name = ctx.scope.instance;
15
+ const path = `providers.${name}.options.reasoningReplay`;
16
+ const textPath = `providers.${name}.options.syntheticReasoningText`;
17
+ const labels: Record<string, string> = { encrypted: S.encrypted, plaintext: S.plaintext };
18
+ const card = ui.sheet({ title: S.title });
19
+ const message = ui.msgline();
20
+ root.append(card.el, message);
21
+
22
+ const outcome = (probe: ProbeOutcome | undefined): string =>
23
+ !probe ? S.skipped : probe.ok ? S.accepted : S.rejected(probe.status, probe.error);
24
+ const describe = (result: DetectResult): string =>
25
+ `${S.outcome(outcome(result.bare), outcome(result.withReasoning))}。${result.verdict ? S.applied(labels[result.verdict]) : S.undetermined}`;
26
+
27
+ async function save(groupId: string, key: string, value: string): Promise<void> {
28
+ try {
29
+ await ctx.setConfig(groupId, { [key]: value });
30
+ message.textContent = S.saved;
31
+ } catch (error) {
32
+ message.textContent = String(error);
33
+ }
34
+ }
35
+
36
+ async function detect(button: HTMLButtonElement): Promise<void> {
37
+ const release = ui.disable(button);
38
+ message.textContent = S.detecting;
39
+ try {
40
+ message.textContent = describe(await ctx.invoke<DetectResult>('detect', [{ name }]));
41
+ } catch (error) {
42
+ message.textContent = String(error);
43
+ } finally {
44
+ release.dispose();
45
+ }
46
+ await load();
47
+ }
48
+
49
+ async function load(): Promise<void> {
50
+ let state: ReasoningPanelState;
51
+ try {
52
+ state = await ctx.invoke<ReasoningPanelState>('state', [{ name }]);
53
+ } catch (error) {
54
+ message.textContent = String(error);
55
+ return;
56
+ }
57
+ if (ctx.signal.aborted) return;
58
+ const { group, values } = state.config[0];
59
+ const property = group.schema.properties[path];
60
+ const current = values[path];
61
+ const select = ui.select({
62
+ value: typeof current === 'string' && current ? current : 'encrypted',
63
+ options: Object.entries(labels).map(([value, label]) => ({ value, label })),
64
+ onChange: (value) => void save(group.id, path, value),
65
+ });
66
+ select.setAttribute('aria-label', property.title);
67
+ const button = ui.button(S.detect, { onClick: () => void detect(button) });
68
+ const row = ui.rowbar();
69
+ row.append(select, button);
70
+ card.body.replaceChildren(ui.field(property.title, row));
71
+ if (property.description) card.body.append(ui.msgline(property.description));
72
+
73
+ const textProperty = group.schema.properties[textPath];
74
+ const text = configField(ui, textProperty, values[textPath], () => {
75
+ if (text.read) void save(group.id, textPath, String(text.read()).trim());
76
+ }, ctx.signal);
77
+ text.node.setAttribute('aria-label', textProperty.title);
78
+ card.body.append(ui.field(textProperty.title, text.node));
79
+ if (textProperty.description) card.body.append(ui.msgline(textProperty.description));
80
+ }
81
+
82
+ await load();
83
+ },
84
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Reasoning section of the endpoint page: the replay form and its probe. The probe sends the
3
+ * shape production sends after an event delivery, a function call the model never made followed by
4
+ * its output, once without reasoning and once with plaintext reasoning, and stores the form the
5
+ * endpoint accepts.
6
+ */
7
+ import type { ConsolePageContribution } from '../../../web/shared/console-protocol.ts';
8
+ import { PROBE_MAX_OUTPUT_TOKENS, type ProviderConsoleHost } from '../../console/types.ts';
9
+ import { getByPath, type ConfigGroup, type ConfigValues } from '../../../core/config-schema.ts';
10
+ import type { LLMProviderEntry, ModelSpec } from '../../../core/types.ts';
11
+ import { GenerationError, type Generation, type ResponseClient } from '../../../core/generation.ts';
12
+ import { functionCall, functionResult, message } from '../../../protocol/open-responses/context.ts';
13
+ import { responseRequest } from '../../../protocol/open-responses/context-helpers.ts';
14
+ import type { ReasoningReplay } from '../../transport/responses-input.ts';
15
+ import { protocolConfig } from '../config.ts';
16
+ import type { CompatControl } from '../index.ts';
17
+ import { text } from '../strings.ts';
18
+
19
+ /** The function the probe's synthetic call names; the endpoint sees a call the model never made. */
20
+ const PROBE_TOOL = 'probe_event_frame';
21
+
22
+ export type ProbeOutcome =
23
+ | { ok: true; status: number | null; reasoning: 'encrypted' | 'plaintext' | 'none' }
24
+ | { ok: false; status: number | null; error: string };
25
+
26
+ export interface DetectResult {
27
+ /** The form written to the entry; null leaves the entry unchanged. */
28
+ verdict: ReasoningReplay | null;
29
+ bare: ProbeOutcome;
30
+ /** Absent when the bare probe failed for a reason other than a rejected request. */
31
+ withReasoning?: ProbeOutcome;
32
+ }
33
+
34
+ export interface ReasoningPanelState {
35
+ name: string;
36
+ config: Array<{ group: ConfigGroup; values: ConfigValues }>;
37
+ }
38
+
39
+ /**
40
+ * Both accepted: the form the endpoint returned, with a reasoning-free response counted as
41
+ * encrypted since nothing is replayed either way. One rejected with 400: the other form.
42
+ * Anything else is undetermined.
43
+ */
44
+ export function replayVerdict(bare: ProbeOutcome, withReasoning: ProbeOutcome): ReasoningReplay | null {
45
+ if (bare.ok && withReasoning.ok) return bare.reasoning === 'plaintext' ? 'plaintext' : 'encrypted';
46
+ if (withReasoning.ok && bare.status === 400) return 'plaintext';
47
+ if (bare.ok && withReasoning.status === 400) return 'encrypted';
48
+ return null;
49
+ }
50
+
51
+ function probeRequest(spec: ModelSpec) {
52
+ const context = [
53
+ message('user', 'Reply with one word.'),
54
+ functionCall('probe_1', PROBE_TOOL, '{}'),
55
+ functionResult('probe_1', 'delivered'),
56
+ ];
57
+ const tools = [{ name: PROBE_TOOL, description: 'Delivers external events.', parameters: { type: 'object', properties: {} } }];
58
+ return {
59
+ ...responseRequest(spec, context, tools),
60
+ max_output_tokens: Math.min(spec.maxTokens ?? PROBE_MAX_OUTPUT_TOKENS, PROBE_MAX_OUTPUT_TOKENS),
61
+ };
62
+ }
63
+
64
+ function reasoningForm(generation: Generation): 'encrypted' | 'plaintext' | 'none' {
65
+ const items = generation.response.output.filter((item) => item.type === 'reasoning');
66
+ if (!items.length) return 'none';
67
+ return items.some((item) => item.encrypted_content) ? 'encrypted' : 'plaintext';
68
+ }
69
+
70
+ async function probe(client: ResponseClient, spec: ModelSpec): Promise<ProbeOutcome> {
71
+ try {
72
+ const generation = await client.respond(probeRequest(spec), { diagnostic: true, nativeSpec: spec, role: 'probe' });
73
+ return { ok: true, status: generation.attempts.at(-1)?.status ?? null, reasoning: reasoningForm(generation) };
74
+ } catch (error) {
75
+ if (error instanceof GenerationError) return { ok: false, status: error.status, error: error.body || error.message };
76
+ return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
77
+ }
78
+ }
79
+
80
+ export function compatConsole(host: ProviderConsoleHost): Partial<ConsolePageContribution> {
81
+ const S = text(host.language);
82
+ const body = (args: unknown[]): string => {
83
+ const [raw] = args;
84
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(S.bodyRequired);
85
+ const name = (raw as Record<string, unknown>).name;
86
+ if (typeof name !== 'string') throw new Error(S.instanceNameRequired);
87
+ return name;
88
+ };
89
+ const entryOf = (name: string): LLMProviderEntry => {
90
+ const found = host.entries().find((entry) => entry.name === name);
91
+ if (!found) throw new Error(S.instanceNameRequired);
92
+ return found.entry;
93
+ };
94
+ const detect = async (name: string): Promise<DetectResult> => {
95
+ const entry = entryOf(name);
96
+ if (!entry.spec) throw new Error(S.modelRequired);
97
+ if (!entry.spec.thinking) throw new Error(S.thinkingOff);
98
+ const control = host.instance(name).control as CompatControl;
99
+ const bare = await probe(control.probeClient('encrypted'), entry.spec);
100
+ if (!bare.ok && bare.status !== 400) return { verdict: null, bare };
101
+ const withReasoning = await probe(control.probeClient('plaintext'), entry.spec);
102
+ const verdict = replayVerdict(bare, withReasoning);
103
+ if (verdict) host.save(name, { ...entry, options: { ...entry.options, reasoningReplay: verdict } });
104
+ return { verdict, bare, withReasoning };
105
+ };
106
+ return {
107
+ config: [],
108
+ panels: [{ id: 'reasoning', title: S.reasoningPanel, description: S.reasoningPanelDescription, slot: 'instance' }],
109
+ invoke: async (panel, method, args) => {
110
+ if (panel !== 'reasoning') throw new Error(S.unknownPanel);
111
+ const name = body(args);
112
+ if (method === 'state') {
113
+ const entry = entryOf(name);
114
+ const prefix = `providers.${name}.`;
115
+ const config = protocolConfig(name, entry, host.language).map((group) => ({
116
+ group,
117
+ values: Object.fromEntries(Object.keys(group.schema.properties).map((path) => [
118
+ path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length)) ?? '',
119
+ ])) as ConfigValues,
120
+ }));
121
+ return { name, config } satisfies ReasoningPanelState;
122
+ }
123
+ if (method === 'detect') return detect(name);
124
+ throw new Error(S.unknownMethod);
125
+ },
126
+ };
127
+ }
@@ -1,8 +1,12 @@
1
1
  import type { ProviderModule } from '../base.ts';
2
2
  import type { LLMProviderEntry } from '../../core/types.ts';
3
+ import type { ResponseClient } from '../../core/generation.ts';
3
4
  import { isContextOverflow } from '../transport/errors.ts';
4
- import { ModelCatalog, ResponsesProvider } from './native.ts';
5
+ import { REASONING_REPLAYS, type ReasoningReplay } from '../transport/responses-input.ts';
6
+ import { ModelCatalog, ResponsesProvider, type ResponsesProviderOptions } from './native.ts';
5
7
  import { text } from './strings.ts';
8
+ import { protocolConfig } from './config.ts';
9
+ import { compatConsole } from './console/server.ts';
6
10
 
7
11
  /** Suggested base URLs for the console field; operators may enter other URLs. */
8
12
  const BASE_URLS: readonly string[] = [
@@ -19,15 +23,25 @@ export interface CompatOptions {
19
23
  endpointPath?: string;
20
24
  extraHeaders?: Record<string, string>;
21
25
  extraBody?: Record<string, unknown>;
26
+ /** Default `encrypted`. */
27
+ reasoningReplay?: ReasoningReplay;
28
+ /** Plaintext replay only; defaults to `SYNTHETIC_REASONING_TEXT`. */
29
+ syntheticReasoningText?: string;
22
30
  }
23
31
  export function compatOptions(entry: LLMProviderEntry): CompatOptions {
24
32
  return (entry.options ?? {}) as CompatOptions;
25
33
  }
26
34
 
35
+ /** Module-owned control object, used by the endpoint page's reasoning section. */
36
+ export interface CompatControl {
37
+ /** A client for this endpoint with the given replay form; the stored entry is unchanged. */
38
+ probeClient(replay: ReasoningReplay): ResponseClient;
39
+ }
40
+
27
41
  /** Empty strings and empty objects are the console's "unset"; they do not reach the wire. */
28
42
  function normalizeCompat(entry: LLMProviderEntry): LLMProviderEntry {
29
43
  const options: Record<string, unknown> = { ...entry.options };
30
- if (options.endpointPath === '') delete options.endpointPath;
44
+ for (const key of ['endpointPath', 'reasoningReplay', 'syntheticReasoningText'] as const) if (options[key] === '') delete options[key];
31
45
  for (const key of ['extraHeaders', 'extraBody'] as const) {
32
46
  const value = options[key];
33
47
  if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) delete options[key];
@@ -42,9 +56,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
42
56
  export default {
43
57
  id: 'openai-responses-compat',
44
58
  title: 'OpenAI Responses Compatible',
59
+ description: 'Connect to Responses-compatible model services.',
60
+ localize: (language) => ({ description: language === 'zh' ? '连接兼容 Responses API 的模型服务。' : 'Connect to Responses-compatible model services.' }),
45
61
  defaultBaseUrl: BASE_URLS[0],
46
62
  baseUrlSuggestions: BASE_URLS,
47
63
  normalize: normalizeCompat,
64
+ config: protocolConfig,
65
+ console: compatConsole,
48
66
  reasoningTiers: [],
49
67
  effortSuggestions: EFFORTS,
50
68
  serviceTiers: [],
@@ -57,6 +75,12 @@ export default {
57
75
  || Object.values(options.extraHeaders).some((value) => typeof value !== 'string')))
58
76
  throw new Error(S.extraHeadersObject);
59
77
  if (options.extraBody !== undefined && !isPlainObject(options.extraBody)) throw new Error(S.extraBodyObject);
78
+ if (options.reasoningReplay !== undefined && !REASONING_REPLAYS.includes(options.reasoningReplay))
79
+ throw new Error(S.reasoningReplayValue);
80
+ // 端点拒收空的 reasoning_text:全空白与空串一样会让明文形态整条请求 400。
81
+ if (options.syntheticReasoningText !== undefined
82
+ && (typeof options.syntheticReasoningText !== 'string' || !options.syntheticReasoningText.trim()))
83
+ throw new Error(S.syntheticReasoningTextValue);
60
84
  },
61
85
  contextOverflow: isContextOverflow,
62
86
  create(name, entry, host) {
@@ -67,20 +91,24 @@ export default {
67
91
  ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
68
92
  });
69
93
  const catalog = new ModelCatalog(() => ({ baseUrl: entry.baseUrl, headers: headers() }));
94
+ const clientOptions = (reasoningReplay: ReasoningReplay | undefined): ResponsesProviderOptions => ({
95
+ baseUrl: entry.baseUrl,
96
+ apiKey,
97
+ endpointPath: options.endpointPath,
98
+ extraHeaders: options.extraHeaders,
99
+ extraBody: options.extraBody,
100
+ log: host.log,
101
+ media: { enabled: () => entry.multimodal === true, read: host.readBlob },
102
+ keepThinking: host.keepThinking,
103
+ reasoningReplay,
104
+ syntheticReasoningText: options.syntheticReasoningText,
105
+ });
70
106
  return {
71
107
  listModels: () => catalog.list(),
72
108
  contextWindow: (model) => catalog.contextWindow(model),
73
109
  compatibilityKey: () => [options.endpointPath ?? '/responses', name],
74
- client: new ResponsesProvider({
75
- baseUrl: entry.baseUrl,
76
- apiKey,
77
- endpointPath: options.endpointPath,
78
- extraHeaders: options.extraHeaders,
79
- extraBody: options.extraBody,
80
- log: host.log,
81
- media: { enabled: () => entry.multimodal === true, read: host.readBlob },
82
- keepThinking: host.keepThinking,
83
- }),
110
+ control: { probeClient: (replay) => new ResponsesProvider(clientOptions(replay)) } satisfies CompatControl,
111
+ client: new ResponsesProvider(clientOptions(options.reasoningReplay)),
84
112
  };
85
113
  },
86
114
  } satisfies ProviderModule;
@@ -7,7 +7,7 @@ import { createResponse, type Request } from '../../protocol/open-responses/inde
7
7
  import { standardUsage, type GenerateOptions } from '../../core/generation.ts';
8
8
  import { NativeResponseAssembly, type ResponseAssembly, type parseChatResponse } from '../transport/response-assembly.ts';
9
9
  import { responseMeters } from '../transport/response-meters.ts';
10
- import { responsesInput, type ResponsesInputOptions } from '../transport/responses-input.ts';
10
+ import { responsesInput, type ReasoningReplay, type ResponsesInputOptions } from '../transport/responses-input.ts';
11
11
 
12
12
  type Item = Record<string, unknown>;
13
13
 
@@ -21,13 +21,15 @@ export interface ResponsesProviderOptions {
21
21
  extraBody?: Record<string, unknown>;
22
22
  media?: ResponsesInputOptions['media'];
23
23
  keepThinking?: () => boolean;
24
+ reasoningReplay?: ReasoningReplay;
25
+ syntheticReasoningText?: string;
24
26
  log?: Logger;
25
27
  }
26
28
 
27
29
  /**
28
30
  * Send reasoning only when an effort is supplied; otherwise leave it to the endpoint.
29
- * Requests set store=false and request encrypted reasoning content for local stateless replay.
30
- * Endpoint extraBody fields are merged last.
31
+ * Requests set store=false and request encrypted reasoning content for local stateless replay;
32
+ * how recorded reasoning is replayed follows `reasoningReplay`. Endpoint extraBody fields are merged last.
31
33
  */
32
34
  export function buildResponsesBody(
33
35
  request: Request,
@@ -61,7 +63,10 @@ export class ResponsesProvider extends OpenAIHttpClient {
61
63
  }
62
64
 
63
65
  protected override buildResponseBody(request: Request, options: GenerateOptions): Record<string, unknown> {
64
- return buildResponsesBody(request, options, { media: this.opts.media, keepThinking: this.opts.keepThinking }, this.opts.extraBody);
66
+ return buildResponsesBody(request, options, {
67
+ media: this.opts.media, keepThinking: this.opts.keepThinking, reasoningReplay: this.opts.reasoningReplay,
68
+ syntheticReasoningText: this.opts.syntheticReasoningText,
69
+ }, this.opts.extraBody);
65
70
  }
66
71
 
67
72
  /** The Chat body builder is unreachable here: `buildResponseBody` is overridden whole. */
@@ -1,12 +1,27 @@
1
1
  import { pick, type Language } from '../../core/language.ts';
2
2
 
3
- /** Server side: ConfigGroup titles and validation errors. */
3
+ /** Server side: ConfigGroup titles, panel titles and validation errors. */
4
4
  const zh = {
5
5
  endpointPath: 'Responses 端点路径',
6
6
  endpointPathDescription: '相对供应地址;默认 /responses。',
7
7
  endpointPathSlash: '端点路径必须以 / 开头',
8
8
  extraHeadersObject: '附加请求头必须是字符串到字符串的对象',
9
9
  extraBodyObject: '附加请求体字段必须是对象',
10
+ reasoningReplay: '思维链回传形态',
11
+ reasoningReplayDescription: '加密回签名块,明文回推理文字;上游接受哪种由端点决定,不确定就探测。',
12
+ reasoningReplayValue: '思维链回传形态只能是 encrypted 或 plaintext',
13
+ syntheticReasoningText: '合成推理填充',
14
+ syntheticReasoningTextDescription: (fallback: string) =>
15
+ `明文回传时,没有记录来源的工具调用前补的那一段推理,模型会读到。留空用默认「${fallback}」;端点拒收空串与全空白。`,
16
+ syntheticReasoningTextValue: '合成推理填充不能是空串或全空白:端点会拒收整条请求',
17
+ reasoningPanel: '思维链',
18
+ reasoningPanelDescription: '思维链回传给上游的形态。',
19
+ bodyRequired: '需要请求体',
20
+ instanceNameRequired: '需要端点名',
21
+ unknownPanel: '未知面板',
22
+ unknownMethod: '未知操作',
23
+ modelRequired: '先选模型',
24
+ thinkingOff: '这条端点关着思维链,回传形态无关',
10
25
  };
11
26
  const en: typeof zh = {
12
27
  endpointPath: 'Responses endpoint path',
@@ -14,6 +29,52 @@ const en: typeof zh = {
14
29
  endpointPathSlash: 'The endpoint path must start with /',
15
30
  extraHeadersObject: 'Extra headers must be an object of strings',
16
31
  extraBodyObject: 'Extra body fields must be an object',
32
+ reasoningReplay: 'Reasoning replay',
33
+ reasoningReplayDescription: 'Encrypted sends the signed block back, plaintext the reasoning text; which one the endpoint accepts is the endpoint\'s, probe when unsure.',
34
+ reasoningReplayValue: 'Reasoning replay must be encrypted or plaintext',
35
+ syntheticReasoningText: 'Synthetic reasoning text',
36
+ syntheticReasoningTextDescription: (fallback: string) =>
37
+ `In plaintext replay, the reasoning sent before a tool call with no recorded origin; the model reads it. Empty uses the default "${fallback}"; endpoints reject an empty or blank string.`,
38
+ syntheticReasoningTextValue: 'Synthetic reasoning text cannot be empty or blank: the endpoint rejects the whole request',
39
+ reasoningPanel: 'Reasoning',
40
+ reasoningPanelDescription: 'The form in which reasoning is sent back to the endpoint.',
41
+ bodyRequired: 'Request body required',
42
+ instanceNameRequired: 'Endpoint name required',
43
+ unknownPanel: 'Unknown panel',
44
+ unknownMethod: 'Unknown method',
45
+ modelRequired: 'Choose a model first',
46
+ thinkingOff: 'Reasoning is off on this endpoint; the replay form does not apply',
17
47
  };
18
48
  export type Text = typeof zh;
19
49
  export const text = (language: Language) => pick(language, { zh, en });
50
+
51
+ /** Client side: the reasoning section of the endpoint page. */
52
+ const panelZh = {
53
+ title: '思维链',
54
+ encrypted: '加密',
55
+ plaintext: '明文',
56
+ detect: '我不知道,测一下',
57
+ detecting: '探测中',
58
+ saved: '已保存',
59
+ accepted: '通过',
60
+ rejected: (status: number | null, error: string) => `被拒${status ? ` ${status}` : ''}:${error}`,
61
+ skipped: '未测',
62
+ outcome: (bare: string, withReasoning: string) => `不带思维链的合成调用:${bare};带明文思维链:${withReasoning}`,
63
+ applied: (label: string) => `已设为${label}`,
64
+ undetermined: '判断不出,设置未改',
65
+ };
66
+ const panelEn: typeof panelZh = {
67
+ title: 'Reasoning',
68
+ encrypted: 'Encrypted',
69
+ plaintext: 'Plaintext',
70
+ detect: 'Not sure, test it',
71
+ detecting: 'Testing',
72
+ saved: 'Saved',
73
+ accepted: 'accepted',
74
+ rejected: (status: number | null, error: string) => `rejected${status ? ` ${status}` : ''}: ${error}`,
75
+ skipped: 'not tested',
76
+ outcome: (bare: string, withReasoning: string) => `Synthetic call without reasoning: ${bare}; with plaintext reasoning: ${withReasoning}`,
77
+ applied: (label: string) => `Set to ${label}`,
78
+ undetermined: 'Undetermined; the setting is unchanged',
79
+ };
80
+ export const panel = { zh: panelZh, en: panelEn };
@@ -64,6 +64,11 @@ export class ProviderRegistry {
64
64
  return module;
65
65
  }
66
66
 
67
+ /** Preview instances use a detached entry and never enter the live instance cache. */
68
+ preview(name: string, entry: LLMProviderEntry): ProviderInstance {
69
+ return new ProviderRegistry(() => ({ [name]: entry }), this.host, this.modules).resolve(name);
70
+ }
71
+
67
72
  resolve(name: string): ProviderInstance {
68
73
  const raw = this.entries()[name];
69
74
  if (!raw) throw new Error(`没有这个 LLM provider: ${name}`);