Skip to content

Commit 9ead0ae

Browse files
authored
fix: repair live model inference edge cases
Fix live model inference edge cases across provider streaming, model switching, outbound delivery, and gateway tool resolution. Includes live/provider issue fixes and leaves #89100 explicitly partial for the remaining FM-2 group routing case.
1 parent 3128ec9 commit 9ead0ae

76 files changed

Lines changed: 2704 additions & 216 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/macos/Sources/OpenClaw/GatewayConnection.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,16 @@ extension GatewayConnection {
514514
var params: [String: AnyCodable] = [
515515
"message": AnyCodable(trimmed),
516516
"sessionKey": AnyCodable(sessionKey),
517-
"thinking": AnyCodable(invocation.thinking ?? "default"),
518517
"deliver": AnyCodable(invocation.deliver),
519518
"to": AnyCodable(invocation.to ?? ""),
520519
"channel": AnyCodable(invocation.channel.rawValue),
521520
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
522521
]
522+
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
523+
!thinking.isEmpty
524+
{
525+
params["thinking"] = AnyCodable(thinking)
526+
}
523527
if let timeout = invocation.timeoutSeconds {
524528
params["timeout"] = AnyCodable(timeout)
525529
}
@@ -664,7 +668,7 @@ extension GatewayConnection {
664668
func chatSend(
665669
sessionKey: String,
666670
message: String,
667-
thinking: String,
671+
thinking: String?,
668672
idempotencyKey: String,
669673
attachments: [OpenClawChatAttachmentPayload],
670674
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
@@ -673,10 +677,14 @@ extension GatewayConnection {
673677
var params: [String: AnyCodable] = [
674678
"sessionKey": AnyCodable(resolvedKey),
675679
"message": AnyCodable(message),
676-
"thinking": AnyCodable(thinking),
677680
"idempotencyKey": AnyCodable(idempotencyKey),
678681
"timeoutMs": AnyCodable(timeoutMs),
679682
]
683+
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
684+
!thinking.isEmpty
685+
{
686+
params["thinking"] = AnyCodable(thinking)
687+
}
680688

681689
if !attachments.isEmpty {
682690
let encoded = attachments.map { att in

apps/macos/Sources/OpenClaw/TalkModeRuntime.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ actor TalkModeRuntime {
387387
let response = try await GatewayConnection.shared.chatSend(
388388
sessionKey: sessionKey,
389389
message: prompt,
390-
thinking: "low",
390+
thinking: nil,
391391
idempotencyKey: runId,
392392
attachments: [])
393393
guard self.isCurrent(gen) else { return }

apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ enum VoiceWakeForwarder {
3434

3535
struct ForwardOptions {
3636
var sessionKey: String = "main"
37-
var thinking: String = "low"
37+
var thinking: String?
3838
var deliver: Bool = true
3939
var to: String?
4040
var channel: GatewayAgentChannel = .webchat
@@ -97,7 +97,6 @@ enum VoiceWakeForwarder {
9797

9898
return ForwardOptions(
9999
sessionKey: sessionKey,
100-
thinking: "low",
101100
deliver: true,
102101
to: to,
103102
channel: channel,

apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,57 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
173173

174174
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
175175
let params = json?["params"] as? [String: Any]
176+
#expect(params?["thinking"] == nil)
176177
#expect(params?["voiceWakeTrigger"] as? String == "")
177178
}
178179

180+
@Test func `chat send omits thinking when inheriting session default`() async throws {
181+
let recorder = WebSocketMessageRecorder()
182+
let session = GatewayTestWebSocketSession(taskFactory: {
183+
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
184+
recorder.append(message)
185+
guard sendIndex > 0,
186+
let data = Self.messageData(message),
187+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
188+
let id = json["id"] as? String
189+
else { return }
190+
task.emitReceiveSuccess(.data(Self.chatSendOkResponseData(id: id)))
191+
})
192+
})
193+
let connection = GatewayConnection(
194+
configProvider: {
195+
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
196+
},
197+
sessionBox: WebSocketSessionBox(session: session))
198+
199+
_ = try await connection.chatSend(
200+
sessionKey: "main",
201+
message: "hello",
202+
thinking: nil,
203+
idempotencyKey: "chat-1",
204+
attachments: [])
205+
await connection.shutdown()
206+
207+
guard let chatMessage = recorder.snapshot().reversed().first(where: { message in
208+
guard let data = Self.messageData(message),
209+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
210+
else { return false }
211+
return json["method"] as? String == "chat.send"
212+
}) else {
213+
Issue.record("expected chat.send websocket payload")
214+
return
215+
}
216+
217+
guard let payloadData = Self.messageData(chatMessage) else {
218+
Issue.record("unexpected chat.send websocket message type")
219+
return
220+
}
221+
222+
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
223+
let params = json?["params"] as? [String: Any]
224+
#expect(params?["thinking"] == nil)
225+
}
226+
179227
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
180228
switch message {
181229
case let .string(text):
@@ -186,4 +234,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
186234
nil
187235
}
188236
}
237+
238+
private static func chatSendOkResponseData(id: String) -> Data {
239+
Data("""
240+
{
241+
"type": "res",
242+
"id": "\(id)",
243+
"ok": true,
244+
"payload": { "runId": "chat-1", "status": "ok" }
245+
}
246+
""".utf8)
247+
}
189248
}

apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Testing
1414
@Test func `forward options defaults`() {
1515
let opts = VoiceWakeForwarder.ForwardOptions()
1616
#expect(opts.sessionKey == "main")
17-
#expect(opts.thinking == "low")
17+
#expect(opts.thinking == nil)
1818
#expect(opts.deliver == true)
1919
#expect(opts.to == nil)
2020
#expect(opts.channel == .webchat)
@@ -38,6 +38,7 @@ import Testing
3838
#expect(opts.channel == .telegram)
3939
#expect(opts.to == "telegram:6812765697")
4040
#expect(opts.voiceWakeTrigger == "open claw")
41+
#expect(opts.thinking == nil)
4142
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
4243
}
4344

docs/cli/onboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ openclaw onboard --non-interactive \
9393

9494
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
9595
OpenClaw marks common vision model IDs as image-capable automatically. Pass `--custom-image-input` for unknown custom vision IDs, or `--custom-text-input` to force text-only metadata.
96+
Use `--custom-compatibility openai-responses` for OpenAI-compatible endpoints that support `/v1/responses` but not `/v1/chat/completions`.
9697

9798
LM Studio also supports a provider-specific key flag in non-interactive mode:
9899

docs/start/wizard-cli-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ What you set:
219219
- `--custom-model-id`
220220
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
221221
- `--custom-provider-id` (optional)
222-
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
222+
- `--custom-compatibility <openai|openai-responses|anthropic>` (optional; default `openai`)
223223
- `--custom-image-input` / `--custom-text-input` (optional; override inferred model input capability)
224224

225225
</Accordion>

extensions/acpx/src/runtime.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
215215
agent: "codex",
216216
mode: "persistent",
217217
model: "gpt-5.4",
218+
sessionOptions: { model: "gpt-5.4" },
218219
});
219220
});
220221

@@ -619,7 +620,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
619620
);
620621
});
621622

622-
it("does not normalize model startup for non-Codex ACP agents", async () => {
623+
it("passes model startup through sessionOptions for non-Codex ACP agents", async () => {
623624
const baseStore: TestSessionStore = {
624625
load: vi.fn(async () => undefined),
625626
save: vi.fn(async () => {}),
@@ -648,6 +649,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
648649
agent: "main",
649650
mode: "persistent",
650651
model: "openai/gpt-5.5",
652+
sessionOptions: { model: "openai/gpt-5.5" },
651653
});
652654
});
653655

@@ -694,6 +696,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
694696
agent: "codex",
695697
mode: "persistent",
696698
model: "gpt-5.5",
699+
sessionOptions: { model: "gpt-5.5" },
697700
});
698701
});
699702

@@ -728,6 +731,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
728731
mode: "persistent",
729732
model: "gpt-5.4/xhigh",
730733
thinking: "x-high",
734+
sessionOptions: { model: "gpt-5.4/xhigh" },
731735
});
732736
});
733737

extensions/acpx/src/runtime.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type AcpRuntimeStatus,
1818
type AcpRuntimeTurn,
1919
type AcpRuntimeTurnResult,
20+
type SessionAgentOptions,
2021
} from "acpx/runtime";
2122
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
2223
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
@@ -49,6 +50,8 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
4950
openclawProcessCleanup?: AcpxProcessCleanupDeps;
5051
};
5152
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
53+
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
54+
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
5255

5356
type ResetAwareSessionStore = AcpSessionStore & {
5457
markFresh: (sessionKey: string) => void;
@@ -547,6 +550,16 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
547550
: override.model;
548551
}
549552

553+
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
554+
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
555+
const model = input.model?.trim() || existingOptions?.model;
556+
const sessionOptions = model ? { ...existingOptions, model } : existingOptions;
557+
return {
558+
...input,
559+
...(sessionOptions ? { sessionOptions } : {}),
560+
} as AcpxDelegateEnsureInput;
561+
}
562+
550563
function quoteShellArg(value: string): string {
551564
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
552565
return value;
@@ -942,7 +955,7 @@ export class AcpxRuntime implements AcpRuntime {
942955
this.withCodexWrapperDiagnostics({
943956
command: stableLaunchCommand,
944957
fallbackCode: "ACP_SESSION_INIT_FAILED",
945-
run: () => delegate.ensureSession(input),
958+
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
946959
}),
947960
});
948961
}
@@ -962,7 +975,7 @@ export class AcpxRuntime implements AcpRuntime {
962975
this.withCodexWrapperDiagnostics({
963976
command: stableLaunchCommand,
964977
fallbackCode: "ACP_SESSION_INIT_FAILED",
965-
run: () => delegate.ensureSession(normalizedInput),
978+
run: () => delegate.ensureSession(withAcpxSessionOptions(normalizedInput)),
966979
}),
967980
),
968981
});

extensions/codex/src/app-server/dynamic-tool-build.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
import {
1919
filterCodexDynamicTools,
2020
resolveCodexDynamicToolsLoading,
21+
resolveCodexDynamicToolsLoadingForModel,
22+
shouldUseDirectCodexDynamicToolsForModel,
2123
} from "./dynamic-tool-profile.js";
2224
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
2325
import { createCodexTestModel } from "./test-support.js";
@@ -179,6 +181,22 @@ describe("Codex app-server dynamic tool build", () => {
179181
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
180182
});
181183

184+
it("uses direct dynamic tools for OpenAI nano models without tool_search support", () => {
185+
const tools = [createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search")];
186+
const toolBridge = createCodexDynamicToolBridge({
187+
tools,
188+
signal: new AbortController().signal,
189+
loading: resolveCodexDynamicToolsLoadingForModel({}, "openai/gpt-5.4-nano"),
190+
});
191+
192+
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
193+
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
194+
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
195+
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
196+
expect(webSearch).not.toHaveProperty("deferLoading");
197+
expect(webSearch).not.toHaveProperty("namespace");
198+
});
199+
182200
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
183201
const messageTool = createRuntimeDynamicTool("message");
184202
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {

0 commit comments

Comments
 (0)