Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ You can also set `commitollama.model` manually in VS Code settings.
| `commitollama.useLowerCase` | Lowercase the first letter of the commit message. | `false` |
| `commitollama.language` | Language preset (`English`, `Spanish`, `Custom`, …). | `English` |
| `commitollama.promptTemperature` | Model temperature (`0`–`1`). Higher = more creative. | `0.2` |
| `commitollama.cloudCompatibilityMode` | Always use JSON-only prompting for Ollama Cloud models that do not support Ollama structured output. Commitollama also falls back to this mode automatically after an invalid structured response. | `false` |
| `commitollama.commitTemplate` | Final commit format. Placeholders: `{{type}}`, `{{emoji}}`, `{{message}}`. | `{{type}} {{emoji}}: {{message}}` |

### Custom overrides
Expand All @@ -69,6 +70,20 @@ You can also set `commitollama.model` manually in VS Code settings.
| `commitollama.custom.descriptionPrompt` | Custom prompt for the commit description. |
| `commitollama.custom.requestHeaders` | Extra HTTP headers for Ollama requests (e.g. auth). |

### Ollama Cloud compatibility

For Ollama Cloud, configure its endpoint and authentication headers. Commitollama automatically retries a failed or incomplete structured response as JSON-only text, extracts a JSON object, and validates it against the commit schema. Enable `commitollama.cloudCompatibilityMode` to use this compatibility path on the first request instead.

```json
{
"commitollama.custom.endpoint": "<your Ollama Cloud endpoint>",
"commitollama.custom.requestHeaders": {
"Authorization": "Bearer <your token>"
},
"commitollama.cloudCompatibilityMode": true
}
```

Example emoji map:

```json
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,17 @@
"default": 0.2,
"order": 7
},
"commitollama.cloudCompatibilityMode": {
"type": "boolean",
"description": "Use JSON-only compatibility mode immediately for Ollama Cloud models that do not support structured output. Failed or incomplete structured responses automatically retry with this mode.",
"default": false,
"order": 8
},
"commitollama.commitTemplate": {
"type": "string",
"description": "Custom template for commit messages.",
"default": "{{type}} {{emoji}}: {{message}}",
"order": 8
"order": 9
},
"commitollama.custom.language": {
"type": "string",
Expand Down
3 changes: 2 additions & 1 deletion sampleWorkspace/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"commitollama.useDescription": false,
"commitollama.useLowerCase": false,
"commitollama.commitTemplate": "{{type}} {{emoji}}: {{message}}",
"commitollama.custom.emojis": {}
"commitollama.custom.emojis": {},
"commitollama.cloudCompatibilityMode": false
}
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const defaultConfig = {
language: Languages.English,
commitTemplate: '{{type}} {{emoji}}: {{message}}',
promptTemperature: 0.2,
cloudCompatibilityMode: false,
requestHeaders: {},
emojis: {
feat: '✨',
Expand Down Expand Up @@ -78,6 +79,9 @@ class Config {

const promptTemperature =
getConfig('promptTemperature') || defaultConfig.promptTemperature
const cloudCompatibilityMode =
getConfig('cloudCompatibilityMode') ??
defaultConfig.cloudCompatibilityMode

const customPrompt = getConfig('custom.prompt')
const customTypeRules = getConfig('custom.typeRules')
Expand All @@ -94,6 +98,7 @@ class Config {

return {
commitEmojis,
cloudCompatibilityMode,
promptTemperature,
commitTemplate,
customCommitMessageRules,
Expand Down
84 changes: 70 additions & 14 deletions src/generator.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as vscode from 'vscode'
import { ZodError } from 'zod'
import * as ai from './ai'
import { isLowQualityCommit } from './commitQuality'
import { config } from './config'
import { OLLAMA_LIBRARY_URL } from './constants'
import { buildCommitModelOptions } from './modelOptions'
import { createChatAdapter } from './ollamaAdapter'
import {
buildCommitSchema,
type CommitStructure,
extractJsonObject,
parseCommitResponse,
} from './schemas/commit'
import { formatExtensionError, logExtensionError } from './security/log'
Expand Down Expand Up @@ -55,6 +58,15 @@ function isStructuredOutputCompatibilityError(error: unknown): boolean {
)
}

function isCloudResponseValidationError(error: unknown): boolean {
return (
error instanceof ZodError ||
(error instanceof Error &&
(error.message === 'Could not find a JSON object in the model response' ||
isStructuredOutputCompatibilityError(error)))
)
}

function formatChangeSummaries(summaries: ChangeSummary[]): string {
return summaries
.map(({ file, summary }) => `- ${file}: ${summary}`)
Expand Down Expand Up @@ -93,6 +105,7 @@ function buildStructuredPrompt(options: {
descriptionPrompt: string
customPrompt?: string
extraInstruction?: string
jsonOnly?: boolean
}): string {
const {
typeRules,
Expand All @@ -102,6 +115,7 @@ function buildStructuredPrompt(options: {
descriptionPrompt,
customPrompt,
extraInstruction,
jsonOnly,
} = options

const basePrompt =
Expand All @@ -126,29 +140,40 @@ function buildStructuredPrompt(options: {
${useDescription ? descriptionPrompt : ''}
Respond using JSON`

if (!extraInstruction) {
const instructions = [
extraInstruction,
jsonOnly
? `Respond with exactly one valid JSON object and nothing else. Do not use Markdown, code fences, or explanatory text. Required fields: ${useDescription ? '{"type":"feat","message":"Brief commit subject","summary":"One to three sentence commit description"}' : '{"type":"feat","message":"Brief commit subject"}'}. The type value must be one of: feat, fix, docs, style, test, chore, revert, refactor.`
: undefined,
].filter(Boolean)

if (instructions.length === 0) {
return basePrompt
}

return `${basePrompt}\n\n${extraInstruction}`
return `${basePrompt}\n\n${instructions.join('\n\n')}`
}

async function requestStructuredCommit(
summaries: ChangeSummary[],
options?: {
extraInstruction?: string
branchName?: string | null
jsonOnly?: boolean
},
): Promise<CommitStructure> {
const {
model,
promptTemperature,
language,
useDescription,
customPrompt,
customTypeRules,
customCommitMessageRules,
customDescriptionPrompt,
cloudCompatibilityMode,
} = config.inference
const jsonOnly = options?.jsonOnly ?? cloudCompatibilityMode

const typeRules =
customTypeRules ||
Expand Down Expand Up @@ -180,28 +205,34 @@ async function requestStructuredCommit(
descriptionPrompt,
customPrompt,
extraInstruction: options?.extraInstruction,
jsonOnly,
})

const outputSchema = buildCommitSchema(useDescription, language)

const result = await ai.chat({
const chatOptions = {
adapter: createChatAdapter(),
systemPrompts: [structuredPrompt],
messages: [
{
role: 'user',
role: 'user' as const,
content: buildCommitUserContent(summaries, options?.branchName),
},
],
outputSchema,
modelOptions: {
options: {
temperature: promptTemperature,
num_predict: 256,
},
think: false,
} as never,
})
modelOptions: buildCommitModelOptions(model, promptTemperature),
}

if (jsonOnly) {
const result = await ai.chat({ ...chatOptions, stream: false })
const commit = parseCommitResponse(
extractJsonObject(result),
useDescription,
language,
)
return commit
}

const result = await ai.chat({ ...chatOptions, outputSchema })

return parseCommitResponse(result, useDescription, language)
}
Expand All @@ -211,11 +242,36 @@ export async function generateStructuredCommit(
branchName?: string | null,
): Promise<CommitStructure> {
try {
let commit = await requestStructuredCommit(summaries, { branchName })
let commit: CommitStructure
let jsonOnly = config.inference.cloudCompatibilityMode

try {
commit = await requestStructuredCommit(summaries, {
branchName,
jsonOnly,
})
} catch (error) {
if (!isCloudResponseValidationError(error)) {
throw error
}

// Cloud models frequently accept chat requests but either reject
// Ollama's structured-output format or omit an optional-looking field
// such as the enabled commit description. Retry as JSON-only text even
// when the compatibility setting was not enabled in advance.
jsonOnly = true
commit = await requestStructuredCommit(summaries, {
branchName,
jsonOnly,
extraInstruction:
'Your previous response was invalid. Return exactly one JSON object with every required field from the requested format.',
})
}

if (isLowQualityCommit(commit.message, commit.type)) {
commit = await requestStructuredCommit(summaries, {
branchName,
jsonOnly,
extraInstruction:
'The previous response was invalid because it did not describe the staged changes. Use the summaries exactly and describe the real code changes.',
})
Expand Down
51 changes: 51 additions & 0 deletions src/modelOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export function isGptOssModel(model: string): boolean {
return /^gpt-oss(?::|$)/i.test(model)
}

export interface OllamaGenerationOptions {
temperature: number
num_predict?: number
}

export interface OllamaModelOptions {
options: OllamaGenerationOptions
think?: 'low' | false
}

export function buildCommitModelOptions(
model: string,
promptTemperature: number,
): OllamaModelOptions {
const isGptOss = isGptOssModel(model)

return {
options: {
temperature: promptTemperature,
// GPT-OSS cannot disable reasoning. Give its low-effort trace enough
// room to finish before it emits the short, visible JSON response.
num_predict: isGptOss ? 2048 : 256,
},
think: isGptOss ? 'low' : false,
}
}

export function buildSummarizeModelOptions(
model: string,
promptTemperature: number,
): OllamaModelOptions {
if (!isGptOssModel(model)) {
return {
options: { temperature: promptTemperature },
}
}

return {
options: {
temperature: promptTemperature,
// The summarize adapter otherwise maps maxLength directly to an
// 80-token generation cap, which GPT-OSS can exhaust on reasoning.
num_predict: 1024,
},
think: 'low',
}
}
21 changes: 17 additions & 4 deletions src/ollamaAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AnySummarizeAdapter, AnyTextAdapter } from '@tanstack/ai'
import { createOllamaChat, createOllamaSummarize } from '@tanstack/ai-ollama'
import {
ChatStreamSummarizeAdapter,
type AnySummarizeAdapter,
type AnyTextAdapter,
} from '@tanstack/ai/adapters'
import { createOllamaChat } from '@tanstack/ai-ollama'
import { config } from './config'

export interface OllamaConnectionConfig {
Expand Down Expand Up @@ -38,6 +42,15 @@ export function createChatAdapter(): AnyTextAdapter {

export function createSummarizeAdapter() {
const { model } = config.inference
const { host } = getOllamaConnectionConfig()
return createOllamaSummarize(model, host) as unknown as AnySummarizeAdapter
const { host, headers } = getOllamaConnectionConfig()

// createOllamaSummarize accepts only a host, so it silently loses custom
// headers (including the authorization header required by Ollama Cloud).
// Wrap the configured chat adapter instead so summaries and commit messages
// use the identical connection configuration.
return new ChatStreamSummarizeAdapter(
createOllamaChat(model, { host, headers }),
model,
'ollama',
) as unknown as AnySummarizeAdapter
}
Loading