Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
80f16b3
feat(api): add abort signal support to all completePrompt
easonliang28 Jul 1, 2026
405a248
test(api): add missing test coverage for abort signal and timeout han…
easonliang28 Jul 1, 2026
23f5831
fix(api): align timeoutMs handling and abort signal wiring across pro…
easonliang28 Jul 1, 2026
3f38fef
test(api): strengthen abort signal merge tests across providers
easonliang28 Jul 1, 2026
921c0d6
test(api): fix abort signal propagation test timing
easonliang28 Jul 1, 2026
e26dc71
feat(api): implement abort/timeout support for native-ollama provider
easonliang28 Jul 1, 2026
103dc75
fix(native-ollama): tighten abort tests and pre-abort error handling
easonliang28 Jul 1, 2026
15415fb
chore: fix test comment in openai-native.spec.ts
easonliang28 Jul 1, 2026
3e1aa72
fix(requesty): update test to expect second createOptions param in co…
easonliang28 Jul 2, 2026
8b54ca2
Merge branch 'feat/abort-signal-core/complete-prompt-abort-pr' of htt…
easonliang28 Jul 5, 2026
f64812b
test(vertex): align anthropic timeout mock
easonliang28 Jul 5, 2026
bde4116
test(anthropic-vertex): tighten abort signal forwarding assertion
easonliang28 Jul 5, 2026
0201624
Merge branch 'main' into feat/abort-signal-core/complete-prompt-abort-pr
easonliang28 Jul 7, 2026
8b553f3
fix(api): add abortSignal aborted check in completePrompt for OpenAI-…
easonliang28 Jul 7, 2026
a919b0f
Merge branch 'main' into feat/abort-signal-core/complete-prompt-abort-pr
easonliang28 Jul 11, 2026
f02f641
fix(providers): align abort timeout handling
easonliang28 Jul 11, 2026
43971d8
fix(providers): guard aborted completions
easonliang28 Jul 11, 2026
32c0fc1
refactor(providers): reuse abort timeout helper for bedrock
easonliang28 Jul 11, 2026
397d8f8
test(providers): cover abort timeout edge cases
easonliang28 Jul 11, 2026
3f07ac2
refactor(providers): align codex abort timeout helper
easonliang28 Jul 11, 2026
70cdaf2
test(api): assert vertex abort signal identity
easonliang28 Jul 11, 2026
5ca9150
test(providers): cover provider completion options
easonliang28 Jul 12, 2026
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
11 changes: 10 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,17 @@ import {
} from "./providers"
import { NativeOllamaHandler } from "./providers/native-ollama"

/**
* Options for completePrompt — unified with ApiHandlerCreateMessageMetadata.
* Uses abortSignal (not signal) to match the metadata pattern used in stream path.
*/
export interface CompletePromptOptions extends Pick<ApiHandlerCreateMessageMetadata, "abortSignal"> {
/** Optional timeout override (ms) — falls back to provider default if omitted */
timeoutMs?: number
}

export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
completePrompt(prompt: string, metadata?: CompletePromptOptions): Promise<string>
}

export interface ApiHandlerCreateMessageMetadata {
Expand Down
94 changes: 82 additions & 12 deletions src/api/providers/__tests__/anthropic-vertex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,18 +834,22 @@ describe("VertexHandler", () => {

const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(handler["client"].messages.create).toHaveBeenCalledWith({
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
})
expect(handler["client"].messages.create).toHaveBeenCalledWith(
{
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
thinking: undefined,
},
undefined,
)
})

it("should handle API errors for Claude", async () => {
Expand Down Expand Up @@ -896,6 +900,72 @@ describe("VertexHandler", () => {
expect(result).toBe("")
})

it("should pass abort signal through to client", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const controller = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {
signal: controller.signal,
})
})

it("should work without options (backward compatible)", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const mockCreate = vitest.fn().mockResolvedValue({
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined)
})

it("completePrompt should pass signal through to client", async () => {
const controller = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })

const [, requestOptions] = mockCreate.mock.calls[0]
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 5000 }),
)
expect(requestOptions.signal).toBe(controller.signal)
})

it("completePrompt should pass timeoutMs when provided", async () => {
const mockCreate = vitest.fn().mockResolvedValue({
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

await handler.completePrompt("test prompt", { timeoutMs: 3000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 3000 }),
)
})

it("should handle empty content array for Claude", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
Expand Down
99 changes: 91 additions & 8 deletions src/api/providers/__tests__/anthropic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,17 @@ describe("AnthropicHandler", () => {
it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "Test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
})
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "Test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
undefined,
)
})

it("should handle API errors", async () => {
Expand All @@ -491,6 +494,86 @@ describe("AnthropicHandler", () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})

it("should pass abort signal through to client", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
{ signal: controller.signal },
)
})

it("should work without options (backward compatible)", async () => {
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
undefined,
)
})

it("should merge signal and timeout together", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 10000 })
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
expect.objectContaining({ signal: controller.signal, timeout: 10000 }),
)
})

it("should pass timeoutMs through to client alongside abortSignal", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: mockOptions.apiModelId }),
expect.objectContaining({ signal: controller.signal, timeout: 5000 }),
)
})

it("should pass the same signal instance", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ signal: controller.signal }),
)
// Verify it's the exact same instance, not just equal
const callOptions = mockCreate.mock.calls[0][1]
expect(callOptions?.signal).toBe(controller.signal)
})

it("should not include signal-related options when not provided", async () => {
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt")
expect(mockCreate).toHaveBeenCalledWith(expect.any(Object), undefined)
})
})

describe("getModel", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,58 @@ describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => {
}),
)
})

describe("completePrompt", () => {
it("should pass timeout through to client when both signal and timeoutMs provided", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const controller = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: "test-model" }),
expect.objectContaining({ signal: expect.any(AbortSignal), timeout: 5000 }),
)
})

it("should pass only timeoutMs when no signal provided", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { timeoutMs: 3000 })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 3000 })
})

it("should handle timeoutMs=0 as valid value (!== undefined check)", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { timeoutMs: 0 })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 0 })
})

it("should work without options (backward compatible)", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: "test-model" }),
{}, // empty object when no options
)
})
})
})
Loading
Loading