Skip to content
Closed
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 packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const codebaseIndexProviderSchema = z.object({
codebaseIndexGeminiApiKey: z.string().optional(),
codebaseIndexMistralApiKey: z.string().optional(),
codebaseIndexVercelAiGatewayApiKey: z.string().optional(),
codebaseIndexOllamaApiKey: z.string().optional(),
})

export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export const SECRET_STATE_KEYS = [
"codebaseIndexGeminiApiKey",
"codebaseIndexMistralApiKey",
"codebaseIndexVercelAiGatewayApiKey",
"codebaseIndexOllamaApiKey",
"huggingFaceApiKey",
"sambaNovaApiKey",
"zaiApiKey",
Expand Down
8 changes: 8 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2493,6 +2493,12 @@ export const webviewMessageHandler = async (
settings.codebaseIndexVercelAiGatewayApiKey,
)
}
if (settings.codebaseIndexOllamaApiKey !== undefined) {
await provider.contextProxy.storeSecret(
"codebaseIndexOllamaApiKey",
settings.codebaseIndexOllamaApiKey,
)
}

// Send success response first - settings are saved regardless of validation
await provider.postMessageToWebview({
Expand Down Expand Up @@ -2630,6 +2636,7 @@ export const webviewMessageHandler = async (
const hasVercelAiGatewayApiKey = !!(await provider.context.secrets.get(
"codebaseIndexVercelAiGatewayApiKey",
))
const hasOllamaApiKey = !!(await provider.context.secrets.get("codebaseIndexOllamaApiKey"))

provider.postMessageToWebview({
type: "codeIndexSecretStatus",
Expand All @@ -2640,6 +2647,7 @@ export const webviewMessageHandler = async (
hasGeminiApiKey,
hasMistralApiKey,
hasVercelAiGatewayApiKey,
hasOllamaApiKey,
},
})
break
Expand Down
7 changes: 6 additions & 1 deletion src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class CodeIndexConfigManager {
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? ""
const vercelAiGatewayApiKey = this.contextProxy?.getSecret("codebaseIndexVercelAiGatewayApiKey") ?? ""
const ollamaApiKey = this.contextProxy?.getSecret("codebaseIndexOllamaApiKey") ?? ""

// Update instance variables with configuration
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
Expand Down Expand Up @@ -116,6 +117,7 @@ export class CodeIndexConfigManager {

this.ollamaOptions = {
ollamaBaseUrl: codebaseIndexEmbedderBaseUrl,
ollamaApiKey: ollamaApiKey || undefined,
}

this.openAiCompatibleOptions =
Expand Down Expand Up @@ -162,6 +164,7 @@ export class CodeIndexConfigManager {
modelDimension: this.modelDimension,
openAiKey: this.openAiOptions?.openAiNativeApiKey ?? "",
ollamaBaseUrl: this.ollamaOptions?.ollamaBaseUrl ?? "",
ollamaApiKey: this.ollamaOptions?.ollamaApiKey ?? "",
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
geminiApiKey: this.geminiOptions?.apiKey ?? "",
Expand Down Expand Up @@ -263,6 +266,7 @@ export class CodeIndexConfigManager {
const prevProvider = prev?.embedderProvider ?? "openai"
const prevOpenAiKey = prev?.openAiKey ?? ""
const prevOllamaBaseUrl = prev?.ollamaBaseUrl ?? ""
const prevOllamaApiKey = prev?.ollamaApiKey ?? ""
const prevOpenAiCompatibleBaseUrl = prev?.openAiCompatibleBaseUrl ?? ""
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
const prevModelDimension = prev?.modelDimension
Expand Down Expand Up @@ -301,6 +305,7 @@ export class CodeIndexConfigManager {
// Authentication changes (API keys)
const currentOpenAiKey = this.openAiOptions?.openAiNativeApiKey ?? ""
const currentOllamaBaseUrl = this.ollamaOptions?.ollamaBaseUrl ?? ""
const currentOllamaApiKey = this.ollamaOptions?.ollamaApiKey ?? ""
const currentOpenAiCompatibleBaseUrl = this.openAiCompatibleOptions?.baseUrl ?? ""
const currentOpenAiCompatibleApiKey = this.openAiCompatibleOptions?.apiKey ?? ""
const currentModelDimension = this.modelDimension
Expand All @@ -314,7 +319,7 @@ export class CodeIndexConfigManager {
return true
}

if (prevOllamaBaseUrl !== currentOllamaBaseUrl) {
if (prevOllamaBaseUrl !== currentOllamaBaseUrl || prevOllamaApiKey !== currentOllamaApiKey) {
return true
}

Expand Down
268 changes: 268 additions & 0 deletions src/services/code-index/embedders/__tests__/ollama.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ describe("CodeIndexOllamaEmbedder", () => {
expect(embedderWithDefaults.embedderInfo.name).toBe("ollama")
})

it("should initialize with API key when provided", () => {
const embedderWithApiKey = new CodeIndexOllamaEmbedder({
ollamaModelId: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434",
ollamaApiKey: "test-api-key-123",
})
expect(embedderWithApiKey.embedderInfo.name).toBe("ollama")
})

it("should normalize URLs with trailing slashes", async () => {
// Create embedder with URL that has a trailing slash
const embedderWithTrailingSlash = new CodeIndexOllamaEmbedder({
Expand Down Expand Up @@ -166,6 +175,128 @@ describe("CodeIndexOllamaEmbedder", () => {
})
})

describe("API Key Authentication", () => {
it("should include Authorization header when API key is provided", async () => {
const embedderWithApiKey = new CodeIndexOllamaEmbedder({
ollamaModelId: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434",
ollamaApiKey: "test-api-key-123",
})

// Mock successful /api/tags call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
models: [{ name: "nomic-embed-text" }],
}),
} as Response),
)

// Mock successful /api/embed test call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
embeddings: [[0.1, 0.2, 0.3]],
}),
} as Response),
)

await embedderWithApiKey.validateConfiguration()

// Check that Authorization header is included in both calls
expect(mockFetch).toHaveBeenCalledTimes(2)

// First call to /api/tags
expect(mockFetch.mock.calls[0][1]?.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key-123",
})

// Second call to /api/embed
expect(mockFetch.mock.calls[1][1]?.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key-123",
})
})

it("should not include Authorization header when API key is not provided", async () => {
// Mock successful /api/tags call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
models: [{ name: "nomic-embed-text" }],
}),
} as Response),
)

// Mock successful /api/embed test call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
embeddings: [[0.1, 0.2, 0.3]],
}),
} as Response),
)

await embedder.validateConfiguration()

// Check that Authorization header is NOT included
expect(mockFetch).toHaveBeenCalledTimes(2)

// First call to /api/tags
expect(mockFetch.mock.calls[0][1]?.headers).toEqual({
"Content-Type": "application/json",
})

// Second call to /api/embed
expect(mockFetch.mock.calls[1][1]?.headers).toEqual({
"Content-Type": "application/json",
})
})

it("should handle authentication errors with API key", async () => {
const embedderWithApiKey = new CodeIndexOllamaEmbedder({
ollamaModelId: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434",
ollamaApiKey: "invalid-api-key",
})

// Mock 401 Unauthorized response
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 401,
} as Response),
)

const result = await embedderWithApiKey.validateConfiguration()

expect(result.valid).toBe(false)
expect(result.error).toBe("embeddings:ollama.serviceUnavailable")
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:11434/api/tags",
expect.objectContaining({
headers: {
"Content-Type": "application/json",
Authorization: "Bearer invalid-api-key",
},
}),
)
})
})

describe("validateConfiguration", () => {
it("should validate successfully when service is available and model exists", async () => {
// Mock successful /api/tags call
Expand Down Expand Up @@ -323,5 +454,142 @@ describe("CodeIndexOllamaEmbedder", () => {
expect(result.valid).toBe(false)
expect(result.error).toBe("Network timeout")
})

describe("createEmbeddings", () => {
it("should create embeddings successfully without API key", async () => {
const texts = ["Hello world", "Test embedding"]

// Mock successful /api/embed call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
embeddings: [
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
],
}),
} as Response),
)

const result = await embedder.createEmbeddings(texts)

expect(result).toEqual({
embeddings: [
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
],
})

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:11434/api/embed",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "nomic-embed-text",
input: texts,
}),
}),
)
})

it("should create embeddings with API key in Authorization header", async () => {
const embedderWithApiKey = new CodeIndexOllamaEmbedder({
ollamaModelId: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434",
ollamaApiKey: "test-api-key-123",
})

const texts = ["Hello world", "Test embedding"]

// Mock successful /api/embed call
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
embeddings: [
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
],
}),
} as Response),
)

const result = await embedderWithApiKey.createEmbeddings(texts)

expect(result).toEqual({
embeddings: [
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
],
})

// Verify Authorization header is included
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:11434/api/embed",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-api-key-123",
},
body: JSON.stringify({
model: "nomic-embed-text",
input: texts,
}),
}),
)
})

it("should handle authentication error when creating embeddings", async () => {
const embedderWithApiKey = new CodeIndexOllamaEmbedder({
ollamaModelId: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434",
ollamaApiKey: "invalid-api-key",
})

const texts = ["Hello world"]

// Mock 401 Unauthorized response
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 401,
statusText: "Unauthorized",
} as Response),
)

await expect(embedderWithApiKey.createEmbeddings(texts)).rejects.toThrow(
"embeddings:ollama.embeddingFailed",
)

// Verify request included the API key
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:11434/api/embed",
expect.objectContaining({
headers: {
"Content-Type": "application/json",
Authorization: "Bearer invalid-api-key",
},
}),
)
})

it("should handle network errors when creating embeddings", async () => {
const texts = ["Hello world"]

// Mock network error
mockFetch.mockRejectedValueOnce(new Error("Network error"))

await expect(embedder.createEmbeddings(texts)).rejects.toThrow("embeddings:ollama.embeddingFailed")
})
})
})
})
Loading
Loading