Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- The terminal assistant lives in the panel bar beside the SSH terminal on Termix 26.10.3 and later, so it can be resized, moved to a side or the bottom, and ordered with the other panels
- Panel beside the terminal host setting and panel settings for where it opens and how big it is
- OpenAI providers on api.openai.com use the Responses API, so reasoning models such as GPT-5.6 and GPT-6 keep their reasoning while calling tools. OpenAI compatible providers and custom base URLs still use Chat Completions

### Fixed

Expand Down
5 changes: 5 additions & 0 deletions src/backend/context-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const DEFAULT_CONTEXT_WINDOW = 128_000;
const KNOWN_WINDOWS: Array<[RegExp, number]> = [
[/claude/i, 200_000],
[/gpt-4\.1/i, 1_000_000],
// OpenAI's model pages: the chat snapshots hold 128k, the small 5.4 and
// cyber models 400k, GPT-5.4 and later and every GPT-6 1.05M.
[/gpt-5(\.\d+)?-chat/i, 128_000],
[/gpt-5\.4-(mini|nano)|gpt-5\.6-cyber/i, 400_000],
[/gpt-5\.([4-9]|\d{2,})|gpt-6/i, 1_050_000],
[/gpt-5/i, 400_000],
[/gpt-4o|gpt-4-turbo/i, 128_000],
[/\bo[134](-|$)/i, 200_000],
Expand Down
1 change: 1 addition & 0 deletions src/backend/ops/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export function registerOpsRoutes(
ctx.fetch,
await readPrivateAllowlist(ctx.settings),
),
log: ctx.log,
});

/** The window and budget of a provider's model, from what it reports. */
Expand Down
222 changes: 222 additions & 0 deletions src/backend/providers/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { assertOk, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderConfig,
ToolCall,
} from "./types.js";
import { AiProviderError } from "./types.js";

// Some models and unverified organizations reject reasoning summaries.
const reasoningDisabled = new Set<string>();

export function resetResponsesMemory(): void {
reasoningDisabled.clear();
}

export function usesResponsesApi(config: ProviderConfig): boolean {
if (config.providerType !== "openai") return false;
const base = config.baseUrl?.trim();
if (!base) return true;
try {
return /(^|\.)api\.openai\.com$/.test(new URL(base).hostname);
} catch {
return false;
}
}

function toResponsesInput(request: ChatRequest): unknown[] {
const input: unknown[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
input.push({
type: "function_call_output",
call_id: message.toolCallId,
output: message.content,
});
continue;
}

if (message.role === "assistant" && message.toolCalls?.length) {
for (const block of message.reasoningState ?? []) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
continue;
}
const item = block as Record<string, unknown>;
// With store:false, only encrypted reasoning can be replayed without ids.
if (item.type !== "reasoning" || !item.encrypted_content) continue;
const { id: _id, status: _status, ...reasoning } = item;
input.push(reasoning);
}
if (message.content) {
input.push({ role: "assistant", content: message.content });
}
for (const call of message.toolCalls) {
input.push({
type: "function_call",
call_id: call.id,
name: call.name,
arguments: JSON.stringify(call.arguments),
});
}
continue;
}

if (message.role !== "assistant" || message.content) {
input.push({ role: message.role, content: message.content });
}
}
return input;
}

export async function* streamResponses(
config: ProviderConfig,
request: ChatRequest,
url: string,
headers: Record<string, string>,
): AsyncIterable<ChatChunk> {
const memoryKey = `${url}|${request.model}`;
let body: Record<string, unknown> = {
model: request.model,
instructions: request.system,
input: toResponsesInput(request),
stream: true,
store: false,
...(!reasoningDisabled.has(memoryKey)
? { reasoning: { summary: "auto" } }
: {}),
...(request.maxTokens !== undefined
? { max_output_tokens: request.maxTokens }
: {}),
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters,
strict: false,
})),
}
: {}),
};
const post = (payload: object) =>
config.fetch(url, {
method: "POST",
headers,
signal: request.signal,
body: JSON.stringify(payload),
});

let response = await post(body);
if (response.status === 400) {
const detail = await response.text().catch(() => "");
if (/reasoning|summar|encrypted/i.test(detail) && body.reasoning) {
config.log?.warn(
`OpenAI refused reasoning summaries for ${request.model}, continuing without them (no Thinking block): ${detail.slice(0, 300)}`,
);
reasoningDisabled.add(memoryKey);
const { reasoning: _dropped, ...rest } = body;
body = rest;
response = await post(body);
} else {
response = new Response(detail, {
status: response.status,
statusText: response.statusText,
});
}
}
await assertOk(response, "OpenAI");

const calls: ToolCall[] = [];
const reasoning: unknown[] = [];
let usage: { input_tokens?: number; output_tokens?: number } | null = null;
let stopReason: string | undefined;
let previousReasoningKey: string | undefined;

for await (const data of readSseLines(response)) {
if (data === "[DONE]") break;
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}

switch (frame?.type) {
case "response.output_text.delta":
case "response.refusal.delta":
if (typeof frame.delta === "string" && frame.delta) {
yield { type: "text", text: frame.delta };
}
break;
case "response.reasoning_summary_text.delta":
case "response.reasoning_text.delta":
if (typeof frame.delta === "string" && frame.delta) {
// Separate summaries and reasoning items only when they have text.
const key = `${frame.item_id}:${frame.summary_index}`;
if (
previousReasoningKey !== undefined &&
previousReasoningKey !== key
) {
yield { type: "reasoning", text: "\n\n" };
}
previousReasoningKey = key;
yield { type: "reasoning", text: frame.delta };
}
break;
case "response.output_item.done": {
const item = frame.item;
if (item?.type === "reasoning") reasoning.push(item);
if (item?.type !== "function_call" || !item.name) break;
let args: Record<string, unknown> = {};
try {
const parsed = JSON.parse(item.arguments);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
args = parsed;
}
} catch {
// The tool's schema validation reports malformed arguments.
}
calls.push({ id: item.call_id, name: item.name, arguments: args });
break;
}
case "response.completed":
case "response.incomplete":
if (frame.response?.usage) usage = frame.response.usage;
stopReason =
frame.type === "response.incomplete"
? (frame.response?.incomplete_details?.reason ?? "incomplete")
: calls.length
? "tool_calls"
: "stop";
break;
case "response.failed":
throw new AiProviderError(
`OpenAI request failed: ${frame.response?.error?.message ?? "the response failed"}`,
);
case "error":
throw new AiProviderError(`OpenAI request failed: ${frame.message}`);
}
}

// A closed connection is only successful after a terminal event.
if (stopReason === undefined) {
throw new AiProviderError(
"OpenAI closed the stream before the response finished.",
);
}

for (const call of calls) yield { type: "tool_call", call };
if (reasoning.length) yield { type: "reasoning_state", blocks: reasoning };
if (usage) {
yield {
type: "usage",
usage: {
promptTokens: Number(usage.input_tokens) || 0,
completionTokens: Number(usage.output_tokens) || 0,
},
};
}
yield { type: "done", stopReason };
}
11 changes: 11 additions & 0 deletions src/backend/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assertOk, firstCount, joinUrl, readSseLines } from "./http.js";
import { streamResponses, usesResponsesApi } from "./openai-responses.js";
import { createThinkSplitter } from "./think-tags.js";
import type {
ChatChunk,
Expand Down Expand Up @@ -80,6 +81,16 @@ export const openAiAdapter: ProviderAdapter = {
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;

if (usesResponsesApi(config)) {
yield* streamResponses(
config,
request,
joinUrl(baseFor(config), "responses"),
headers,
);
return;
}

let body: Record<string, unknown> = {
model: request.model,
messages: toOpenAiMessages(request),
Expand Down
2 changes: 2 additions & 0 deletions src/backend/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface ProviderConfig {
baseUrl?: string | null;
apiKey?: string | null;
fetch: ProviderFetch;
/** Reports provider fallbacks to the server log. */
log?: { warn(message: string): void };
}

/** What a provider says about one of its models; what it leaves out is unknown. */
Expand Down
1 change: 1 addition & 0 deletions src/backend/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function registerAiRoutes(
baseUrl: input.baseUrl,
apiKey: input.apiKey,
fetch: createProviderFetch(ctx.fetch, allowlist),
log: ctx.log,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/backend/tasks/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function taskResult(
}

export async function providerConfig(
ctx: Pick<PluginContext, "fetch" | "settings">,
ctx: Pick<PluginContext, "fetch" | "settings" | "log">,
provider: AiProviderWithSecret,
): Promise<ProviderConfig> {
return {
Expand All @@ -109,6 +109,7 @@ export async function providerConfig(
ctx.fetch,
await readPrivateAllowlist(ctx.settings),
),
log: ctx.log,
};
}

Expand Down
2 changes: 2 additions & 0 deletions tests/backend/assets/annotate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ async function setup(
await mock.actAs("user-1", () =>
repository.createProvider("user-1", {
providerType: "openai",
// The mock speaks Chat Completions; official OpenAI uses the Responses API.
baseUrl: "https://llm.example/v1",
label: "Work",
apiKey: "sk-test",
defaultModel: "gpt-5",
Expand Down
13 changes: 13 additions & 0 deletions tests/backend/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,19 @@ describe("context windows", () => {
expect(defaultContextWindow("claude-opus-5")).toBe(200_000);
expect(defaultContextWindow("gemini-2.5-pro")).toBe(1_000_000);
expect(defaultContextWindow("gpt-4o-mini")).toBe(128_000);
expect(defaultContextWindow("gpt-5")).toBe(400_000);
expect(defaultContextWindow("gpt-5.2-codex")).toBe(400_000);
expect(defaultContextWindow("gpt-5-chat-latest")).toBe(128_000);
expect(defaultContextWindow("gpt-5.3-chat-latest")).toBe(128_000);
expect(defaultContextWindow("gpt-5.4-mini")).toBe(400_000);
expect(defaultContextWindow("gpt-5.6-cyber")).toBe(400_000);
expect(defaultContextWindow("gpt-5.4")).toBe(1_050_000);
expect(defaultContextWindow("gpt-5.5-pro")).toBe(1_050_000);
expect(defaultContextWindow("gpt-5.6-luna")).toBe(1_050_000);
expect(defaultContextWindow("gpt-5.6-terra")).toBe(1_050_000);
expect(defaultContextWindow("gpt-6-luna")).toBe(1_050_000);
expect(defaultContextWindow("gpt-6-astra")).toBe(1_050_000);
expect(defaultContextWindow("gpt-6.1-sol")).toBe(1_050_000);
expect(defaultContextWindow("something-new")).toBe(128_000);
expect(defaultContextWindow(null)).toBe(128_000);
});
Expand Down
2 changes: 2 additions & 0 deletions tests/backend/ops/assistant-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ async function addProvider(target: TestServer): Promise<number> {
const created = await target.request("POST", "/providers", {
body: {
providerType: "openai",
// The mock speaks Chat Completions; official OpenAI uses the Responses API.
baseUrl: "https://llm.example/v1",
label: "Work",
apiKey: "sk-test",
defaultModel: "gpt-5",
Expand Down
2 changes: 2 additions & 0 deletions tests/backend/ops/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ async function addProvider(target: TestServer): Promise<number> {
const created = await target.request("POST", "/providers", {
body: {
providerType: "openai",
// The mock speaks Chat Completions; official OpenAI uses the Responses API.
baseUrl: "https://llm.example/v1",
label: "Work",
apiKey: "sk-test",
defaultModel: "gpt-5",
Expand Down
2 changes: 2 additions & 0 deletions tests/backend/ops/runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ async function setup(
const created = await server.request("POST", "/providers", {
body: {
providerType: "openai",
// The mock speaks Chat Completions; official OpenAI uses the Responses API.
baseUrl: "https://llm.example/v1",
label: "Work",
apiKey: "sk-test",
defaultModel: "gpt-5",
Expand Down
Loading