Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d56bda1
feat(api): abort signal support for native-ollama (completePrompt + c…
easonliang28 Aug 19, 2026
346eeb3
fix(api): address CodeRabbit review on native-ollama abort handling
easonliang28 Aug 20, 2026
b3449a2
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Aug 30, 2026
6d59fad
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Sep 1, 2026
1a21368
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-nativ…
easonliang28 Sep 2, 2026
df58e60
fix(api): harden native Ollama abort handling and credential gating
easonliang28 Sep 2, 2026
45c5c0a
fix(api): harden native Ollama cancellation and credential routing
easonliang28 Sep 3, 2026
ec8a491
fix(api): harden Ollama loopback check and stream finalization
easonliang28 Sep 3, 2026
e08e012
Merge upstream/main (79cd12f2c) into feat/abort-r1-native-ollama
easonliang28 Sep 3, 2026
5e1b719
fix(ci): declare vitest as a root devDependency so the mutation gate …
easonliang28 Sep 3, 2026
dc36ac7
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Sep 3, 2026
95155b0
fix(api): harden native Ollama abort/timeout paths and add mutation-k…
easonliang28 Sep 3, 2026
0afb9bf
Merge remote-tracking branch 'origin/feat/abort-r1-native-ollama' int…
easonliang28 Sep 3, 2026
192059d
fix(api): kill remaining Ollama mutation survivors (thought tag, Abor…
easonliang28 Sep 3, 2026
c38b376
Merge branch main into feat/abort-r1-native-ollama
easonliang28 Sep 6, 2026
efb32e2
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Sep 15, 2026
d8503d8
Merge remote-tracking branch 'upstream/main' into fix-pr-1299
easonliang28 Sep 27, 2026
d2d0cbf
chore: sync pnpm lockfile with root vitest devDependency
easonliang28 Sep 27, 2026
8cb94a5
test: replace double assertions in timer fakes with single structural…
easonliang28 Sep 27, 2026
37dbd62
chore: bump root vitest to 4.1.11 to clear dependency-review finding
easonliang28 Sep 27, 2026
718a89f
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Sep 28, 2026
4389c9c
fix(native-ollama): detach abort listener, cancel discovery, align vi…
Sep 28, 2026
e34b840
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Sep 29, 2026
d95082b
fix(native-ollama): prove the forwarded discovery signal is linked to…
Sep 29, 2026
2c4feb7
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-nativ…
Oct 2, 2026
7c1d85f
Merge commit 'refs/shapes/upstream-main' into feat/abort-r1-native-ol…
Oct 2, 2026
4172aa2
Merge branch 'main' into feat/abort-r1-native-ollama
easonLiangWorldedtech Oct 5, 2026
bf76e72
Merge upstream main (285f6687a) into feat/abort-r1-native-ollama
easonliang28 Oct 13, 2026
4e318eb
Address review: caller-side transport cancellation coverage, credenti…
easonliang28 Oct 13, 2026
fa64caf
Revert remaining vitest peer bumps with the dependency move
easonliang28 Oct 13, 2026
91dc04c
Address review: reuse shared abort-signal helpers; warn on dropped AP…
easonliang28 Oct 14, 2026
d57c6cf
Merge upstream/main (58341d882) into feat/abort-r1-native-ollama
easonliang28 Oct 14, 2026
6e216e2
chore(api): remove Stryker suppression comments from touched files
easonliang28 Oct 14, 2026
e7ca0b0
fix(api): echo only the endpoint origin in the credential-drop warning
easonliang28 Oct 14, 2026
2c34c18
fix(api): reject non-finite and cap overflowing timeoutMs delays
easonliang28 Oct 14, 2026
153ffab
fix(api): round fractional timeouts and pin the aborted-outcome asser…
easonliang28 Oct 14, 2026
6319fe5
test(api): assert the rounded delay reaching AbortSignal.timeout
easonliang28 Oct 14, 2026
0d18b4d
fix(api): round fractional completion timeouts up before scheduling
easonliang28 Oct 14, 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
1,359 changes: 1,354 additions & 5 deletions src/api/providers/__tests__/native-ollama.spec.ts

Large diffs are not rendered by default.

224 changes: 178 additions & 46 deletions src/api/providers/fetchers/__tests__/ollama.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from "axios"

import { getOllamaModels, parseOllamaModel } from "../ollama"
import { getOllamaModels, isIpv4LoopbackHost, isSecureOllamaEndpoint, parseOllamaModel } from "../ollama"
import ollamaModelsData from "./fixtures/ollama-model-details.json"

// Mock axios
Expand Down Expand Up @@ -116,7 +116,175 @@ describe("Ollama Fetcher", () => {
})
})

describe("isSecureOllamaEndpoint", () => {
it("should treat loopback HTTP endpoints as safe", () => {
expect(isSecureOllamaEndpoint("http://localhost:11434")).toBe(true)
expect(isSecureOllamaEndpoint("http://127.0.0.1:11434")).toBe(true)
expect(isSecureOllamaEndpoint("http://[::1]:11434")).toBe(true)
})

it("should only treat strict 127.0.0.0/8 IPv4 literals as safe", () => {
// Regression: the old /^127\./ prefix also matched DNS names such as
// 127.example.com, which would leak the key over cleartext HTTP.
expect(isSecureOllamaEndpoint("http://127.example.com:11434")).toBe(false)
expect(isSecureOllamaEndpoint("http://127.99.99.99:11434")).toBe(true) // rest of 127.0.0.0/8
expect(isSecureOllamaEndpoint("http://127.0.0.255:11434")).toBe(true) // range edge
expect(isSecureOllamaEndpoint("http://1270.0.0.1:11434")).toBe(false) // 1270 is not a valid octet
expect(isSecureOllamaEndpoint("http://127.256.0.1:11434")).toBe(false)
expect(isSecureOllamaEndpoint("http://one.two.three.four:11434")).toBe(false)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("should treat any HTTPS endpoint as safe", () => {
expect(isSecureOllamaEndpoint("https://ollama.example.com")).toBe(true)
expect(isSecureOllamaEndpoint("https://10.0.0.5:11434")).toBe(true)
})

it("should treat remote HTTP endpoints as unsafe", () => {
expect(isSecureOllamaEndpoint("http://ollama.example.com:11434")).toBe(false)
expect(isSecureOllamaEndpoint("http://10.0.0.5:11434")).toBe(false)
})

it("should treat unparseable endpoints as unsafe", () => {
expect(isSecureOllamaEndpoint("not a url")).toBe(false)
})
})

describe("isIpv4LoopbackHost", () => {
it("should accept only four-octet 127/8 literals", () => {
expect(isIpv4LoopbackHost("127.0.0.1")).toBe(true)
expect(isIpv4LoopbackHost("127.255.255.255")).toBe(true)
expect(isIpv4LoopbackHost("0.0.0.1")).toBe(false)
})

it("should reject hosts with the wrong octet count", () => {
expect(isIpv4LoopbackHost("127.0.0")).toBe(false)
expect(isIpv4LoopbackHost("127.0.0.0.1")).toBe(false)
})

it("should reject non-numeric octets even when they parse as numbers", () => {
// Number("0x4") === 4 and Number("1e2") === 100, so only the anchored
// octet regex rejects these inputs.
expect(isIpv4LoopbackHost("127.0.0.0x4")).toBe(false)
expect(isIpv4LoopbackHost("127.1e2.0.1")).toBe(false)
})

it("should reject out-of-range octets", () => {
expect(isIpv4LoopbackHost("127.999.0.1")).toBe(false)
expect(isIpv4LoopbackHost("0127.0.0.1")).toBe(false)
})
})

describe("getOllamaModels", () => {
// Shared /api/tags and /api/show mock payloads. Each call returns fresh
// objects so tests cannot share mutable state.
const makeOllamaTagsPayload = (modelName: string) => ({
models: [
{
name: modelName,
model: modelName,
modified_at: "2025-06-03T09:23:22.610222878-04:00",
size: 14333928010,
digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5",
details: {
family: "llama",
families: ["llama"],
format: "gguf",
parameter_size: "23.6B",
parent_model: "",
quantization_level: "Q4_K_M",
},
},
],
})
const makeOllamaShowPayload = () => ({
license: "Mock License",
modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}",
parameters: "num_ctx 4096\nstop_token <eos>",
template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:",
modified_at: "2025-06-03T09:23:22.610222878-04:00",
details: {
parent_model: "",
format: "gguf",
family: "llama",
families: ["llama"],
parameter_size: "23.6B",
quantization_level: "Q4_K_M",
},
model_info: {
"ollama.context_length": 4096,
"some.other.info": "value",
},
capabilities: ["completion", "tools"], // Has tools capability
})

it("should omit the Authorization header for a remote HTTP endpoint", async () => {
const baseUrl = "http://ollama.example.com:11434"
const apiKey = "test-api-key-123"

mockedAxios.get.mockResolvedValueOnce({ data: { models: [] } })

const result = await getOllamaModels(baseUrl, apiKey)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} })
expect(result).toEqual({})
})

it("should include the Authorization header for an HTTPS endpoint", async () => {
const baseUrl = "https://ollama.example.com:11434"
const apiKey = "test-api-key-123"

mockedAxios.get.mockResolvedValueOnce({ data: { models: [] } })

const result = await getOllamaModels(baseUrl, apiKey)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
expect(result).toEqual({})
})

it.each(["http://localhost:11434", "http://[::1]:11434"])(
"should send the Authorization header and disable proxy routing for a loopback endpoint %s",
async (baseUrl) => {
const apiKey = "test-api-key-123"
const modelName = "devstral2to16:latest"

mockedAxios.get.mockResolvedValueOnce({ data: makeOllamaTagsPayload(modelName) })
mockedAxios.post.mockResolvedValueOnce({ data: makeOllamaShowPayload() })

const result = await getOllamaModels(baseUrl, apiKey)

// A cleartext loopback request carrying the bearer token must not
// be routed through any HTTP proxy, otherwise the proxy would see
// the credential in cleartext (CWE-319). Both requests bypass the
// proxy. The IPv6 loopback [::1] is treated the same as the IPv4
// loopback: the credential is attached and proxy routing is
// disabled.
const expectedHeaders = { Authorization: `Bearer ${apiKey}` }
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, {
headers: expectedHeaders,
proxy: false,
})

expect(mockedAxios.post).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${baseUrl}/api/show`,
{ model: modelName },
{
headers: expectedHeaders,
proxy: false,
},
)

expect(typeof result).toBe("object")
expect(Object.keys(result).length).toBe(1)
expect(result[modelName]).toBeDefined()
},
)

it("should fetch model list from /api/tags and include models with tools capability", async () => {
const baseUrl = "http://localhost:11434"
const modelName = "devstral2to16:latest"
Expand Down Expand Up @@ -336,61 +504,25 @@ describe("Ollama Fetcher", () => {
const apiKey = "test-api-key-123"
const modelName = "test-model:latest"

const mockApiTagsResponse = {
models: [
{
name: modelName,
model: modelName,
modified_at: "2025-06-03T09:23:22.610222878-04:00",
size: 14333928010,
digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5",
details: {
family: "llama",
families: ["llama"],
format: "gguf",
parameter_size: "23.6B",
parent_model: "",
quantization_level: "Q4_K_M",
},
},
],
}
const mockApiShowResponse = {
license: "Mock License",
modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}",
parameters: "num_ctx 4096\nstop_token <eos>",
template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:",
modified_at: "2025-06-03T09:23:22.610222878-04:00",
details: {
parent_model: "",
format: "gguf",
family: "llama",
families: ["llama"],
parameter_size: "23.6B",
quantization_level: "Q4_K_M",
},
model_info: {
"ollama.context_length": 4096,
"some.other.info": "value",
},
capabilities: ["completion", "tools"], // Has tools capability
}

mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse })
mockedAxios.post.mockResolvedValueOnce({ data: mockApiShowResponse })
mockedAxios.get.mockResolvedValueOnce({ data: makeOllamaTagsPayload(modelName) })
mockedAxios.post.mockResolvedValueOnce({ data: makeOllamaShowPayload() })

const result = await getOllamaModels(baseUrl, apiKey)

const expectedHeaders = { Authorization: `Bearer ${apiKey}` }

// Cleartext loopback + bearer token: the fetcher also disables proxy
// routing on both requests (CWE-319).
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: expectedHeaders })
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, {
headers: expectedHeaders,
proxy: false,
})

expect(mockedAxios.post).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${baseUrl}/api/show`,
{ model: modelName },
{ headers: expectedHeaders },
{ headers: expectedHeaders, proxy: false },
)

expect(typeof result).toBe("object")
Expand Down
65 changes: 61 additions & 4 deletions src/api/providers/fetchers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,48 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo |
return modelInfo
}

/**
* Determines whether an Ollama endpoint is safe to carry an API key.
*
* Ollama's default installation listens on loopback, where plaintext HTTP is
* the norm. API keys are secrets, though, and must not be sent in cleartext to
* a remote host (CWE-319). Only HTTPS or a loopback host is considered safe
* enough to attach the Authorization header.
*
* Shared by the axios fetcher (getOllamaModels) and the native provider
* (NativeOllamaHandler), so this strict check gates credentials on both paths.
*/
export function isSecureOllamaEndpoint(baseUrl: string): boolean {
if (!URL.canParse(baseUrl)) {
return false
}
const url = new URL(baseUrl)
if (url.protocol === "https:") {
return true
}
const host = url.hostname
return host === "localhost" || host === "[::1]" || isIpv4LoopbackHost(host)
}

/**
* Strict 127.0.0.0/8 IPv4-loopback literal check. `host` comes from
* `URL.hostname`, which is WHATWG-normalized (IPv4 literals carry no brackets
* and no leading zeros), so the anchored octet check is exact. A loose
* /^127\./ prefix would also match DNS names like `127.example.com`, letting
* the key leak over cleartext HTTP (CWE-319).
*/
export function isIpv4LoopbackHost(host: string): boolean {
const octets = host.split(".")
if (octets.length !== 4) {
return false
}
if (!octets.every((octet) => /^\d{1,3}$/.test(octet))) {
return false
}
const values = octets.map((octet) => Number(octet))
return values[0] === 127 && values.every((value) => value <= 255)
}

export async function getOllamaModels(
baseUrl = "http://localhost:11434",
apiKey?: string,
Expand All @@ -79,13 +121,28 @@ export async function getOllamaModels(
return models
}

// Prepare headers with optional API key
// Prepare headers with optional API key. The credential is only attached
// when the endpoint is HTTPS or loopback; sending it over plaintext HTTP
// to a remote host would leak it (CWE-319).
const credentialGated = Boolean(apiKey && isSecureOllamaEndpoint(baseUrl))
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
const headers: Record<string, string> = {}
if (apiKey) {
if (credentialGated) {
headers["Authorization"] = `Bearer ${apiKey}`
}

const response = await axios.get<OllamaModelsResponse>(`${baseUrl}/api/tags`, { headers, signal: opts?.signal })
// A loopback HTTP endpoint carrying the key must bypass any HTTP proxy,
// otherwise the proxy would see the bearer token in cleartext (CWE-319).
// HTTPS endpoints keep normal proxy behavior (traffic stays encrypted).
// Parsing is safe here: baseUrl is normalized ("" → default) above and
// the !URL.canParse(baseUrl) guard returned early.
const cleartextLoopback = credentialGated && new URL(baseUrl).protocol === "http:"
const proxyConfig: { proxy?: false } = cleartextLoopback ? { proxy: false } : {}

const response = await axios.get<OllamaModelsResponse>(`${baseUrl}/api/tags`, {
headers,
...proxyConfig,
signal: opts?.signal,
})
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
const modelInfoPromises = []

Expand All @@ -98,7 +155,7 @@ export async function getOllamaModels(
{
model: ollamaModel.model,
},
{ headers, signal: opts?.signal },
{ headers, ...proxyConfig, signal: opts?.signal },
)
.then((ollamaModelInfo) => {
const modelInfo = parseOllamaModel(ollamaModelInfo.data)
Expand Down
Loading
Loading