Skip to content

Commit 894657e

Browse files
authored
feat(cz-git,czg): ai api using SSE to support thinking model (#252)
- Add support for thinking models - Introduced streaming capabilities for OpenAI API responses - Updated timeout settings for API requests to accommodate longer response times. - Adjusted maximum token limits for AI completions to improve output quality. link: #250, #248
1 parent d948add commit 894657e

11 files changed

Lines changed: 265 additions & 44 deletions

File tree

‎.npmrc‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎docs/.vitepress/components.d.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ declare module 'vue' {
1010
AdsASide: typeof import('./theme/components/AdsASide.vue')['default']
1111
Badge: typeof import('./theme/components/Badge.vue')['default']
1212
CodeGroupItem: typeof import('./theme/components/CodeGroupItem.vue')['default']
13-
copy: typeof import('./theme/components/StepFlow copy.vue')['default']
1413
HomePage: typeof import('./theme/components/HomePage.vue')['default']
15-
StepFlow: typeof import('./theme/components/StepFlow.vue')['default']
1614
StepFlowItem: typeof import('./theme/components/StepFlowItem.vue')['default']
1715
}
1816
}

‎docs/recipes/openai.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ czg --api-key=sk-xxxxx
6868
1. Get DeepSeek [API Key](https://platform.deepseek.com/api_keys)
6969
2. Run the command to configure
7070
```sh
71-
npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-chat"
71+
npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-v4-flash"
7272
```
7373
:::
7474

‎docs/zh/recipes/openai.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ czg --api-key=sk-xxxxx
7979
1. 获取 DeepSeek [API Key](https://platform.deepseek.com/api_keys)
8080
2. 运行命令进行配置
8181
```sh
82-
npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-chat"
82+
npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-v4-flash"
8383
```
8484
:::
8585

‎package.json‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "cz-git",
33
"version": "1.12.0",
44
"private": true,
5-
"packageManager": "pnpm@9.11.0",
5+
"packageManager": "pnpm@10.33.2",
66
"description": "A better customizable and git support commitizen adapter",
77
"author": "Zhengqbbb <zhengqbbb@gmail.com> (https://github.com/Zhengqbbb)",
88
"license": "MIT",
@@ -69,7 +69,7 @@
6969
"npm-run-all2": "^6.2.3",
7070
"ora": "^8.1.0",
7171
"pathe": "^1.1.2",
72-
"pnpm": "^9.11.0",
72+
"pnpm": "^10.33.2",
7373
"rimraf": "catalog:rimraf",
7474
"simple-git-hooks": "^2.11.1",
7575
"ts-json-schema-generator": "^2.3.0",
@@ -78,28 +78,6 @@
7878
"typescript": "^5.5.4",
7979
"vitest": "^2.0.5"
8080
},
81-
"pnpm": {
82-
"overrides": {
83-
"@commitlint/config-validator": "catalog:commitlint",
84-
"chalk": "4.1.2",
85-
"color-convert": "2.0.1",
86-
"import-meta-resolve": "4.1.0",
87-
"resolve-from": "5.0.0",
88-
"supports-color": "8.1.1"
89-
},
90-
"peerDependencyRules": {
91-
"ignoreMissing": [
92-
"@algolia/client-search",
93-
"@types/react",
94-
"eslint-plugin-import",
95-
"eslint-plugin-n",
96-
"eslint-plugin-promise",
97-
"react",
98-
"react-dom",
99-
"webpack"
100-
]
101-
}
102-
},
10381
"simple-git-hooks": {
10482
"pre-commit": "pnpm lint-staged",
10583
"commit-msg": "pnpm commitlint --edit $1",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Readable } from 'node:stream'
2+
import { describe, expect, it } from 'vitest'
3+
import { readChatCompletionStreamToSubjects } from '../src/shared/utils/stream'
4+
5+
function asStream(body: string): NodeJS.ReadableStream {
6+
return Readable.from([body])
7+
}
8+
9+
describe('readChatCompletionStreamToSubjects', () => {
10+
it('parses non-stream JSON when no SSE choice deltas appear', async () => {
11+
const json = JSON.stringify({
12+
choices: [{ index: 0, message: { role: 'assistant', content: 'fix login redirect' } }],
13+
})
14+
const subjects = await readChatCompletionStreamToSubjects(asStream(json), 1)
15+
expect(subjects).toEqual(['fix login redirect'])
16+
})
17+
18+
it('parses SSE data lines as before', async () => {
19+
const sse = [
20+
'data: {"choices":[{"index":0,"delta":{"content":"hello"}}]}',
21+
'',
22+
'data: {"choices":[{"index":0,"delta":{"content":" world"}}]}',
23+
'',
24+
'data: [DONE]',
25+
'',
26+
].join('\n')
27+
const subjects = await readChatCompletionStreamToSubjects(asStream(sse), 1)
28+
expect(subjects).toEqual(['hello world'])
29+
})
30+
31+
it('throws when body has neither SSE choices nor non-stream completion', async () => {
32+
await expect(readChatCompletionStreamToSubjects(asStream('not json'), 1)).rejects.toThrow(
33+
/no streamed choice deltas/,
34+
)
35+
})
36+
37+
it('does not return a single empty subject when stream is empty', async () => {
38+
await expect(readChatCompletionStreamToSubjects(asStream(''), 1)).rejects.toThrow()
39+
})
40+
})

‎packages/cz-git/src/generator/api.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { style } from '@cz-git/inquirer'
44
import HttpsProxyAgent from 'https-proxy-agent'
55
import { isNodeVersionInRange, log, transformSubjectCase } from '../shared'
66
import type { CommitizenGitOptions } from '../shared'
7+
import { bodyToNodeReadable, readChatCompletionStreamToSubjects } from '../shared/utils/stream'
8+
9+
/** Enough headroom for reasoning + short subject (legacy default was 200). */
10+
const AI_MAX_COMPLETION_TOKENS = 4096
11+
12+
/** Streaming first byte and full generation can exceed the old 10s window. */
13+
const AI_FETCH_TIMEOUT_MS = 60 * 1000
714

815
export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt: string) {
916
if (!options.openAIToken) {
@@ -33,7 +40,7 @@ export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt:
3340
},
3441
method: 'POST',
3542
body: JSON.stringify(aiContext.payload),
36-
signal: isNodeVersionInRange(18) ? AbortSignal?.timeout(10 * 1000) : undefined,
43+
signal: isNodeVersionInRange(18) ? AbortSignal?.timeout(AI_FETCH_TIMEOUT_MS) : undefined,
3744
})
3845

3946
if (
@@ -44,10 +51,11 @@ export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt:
4451
const errorJson: any = await response.json()
4552
throw new APIError(errorJson?.error?.message, response.status)
4653
}
47-
const json: any = await response.json()
48-
return json
49-
.choices
50-
.map((r: any) => parseAISubject(options, aiContext.parseFn(r)))
54+
55+
const choiceCount = options.aiNumber || 1
56+
const readable = bodyToNodeReadable(response.body)
57+
const rawSubjects = await readChatCompletionStreamToSubjects(readable, choiceCount)
58+
return rawSubjects.map(s => parseAISubject(options, s))
5159
}
5260
catch (err: any) {
5361
let errorMsg = 'Fetch OpenAI API message failure.'
@@ -74,14 +82,13 @@ function useModelStrategy(options: CommitizenGitOptions, prompt: string) {
7482
payload: {
7583
model: options.aiModel,
7684
messages: [{ role: 'user', content: prompt }],
77-
stream: false,
85+
stream: true,
7886
top_p: 1,
7987
temperature: 0.7,
80-
max_tokens: 200,
88+
max_tokens: AI_MAX_COMPLETION_TOKENS,
8189
n: options.aiNumber || 1,
8290
},
8391
url: `${options.apiEndpoint}/chat/completions`,
84-
parseFn: (res: any) => res?.message?.content,
8592
}
8693
}
8794

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './editor'
2-
export * from './util'
32
export * from './rule'
3+
export * from './stream'
4+
export * from './util'
45
export * from './wrap'
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @description Parse OpenAI-compatible `chat/completions` streaming (SSE) bodies
3+
* @author Zhengqbbb <zhengqbbb@gmail.com>
4+
* @license MIT
5+
*/
6+
7+
import { Buffer } from 'node:buffer'
8+
import { Readable } from 'node:stream'
9+
import type { ReadableStream as WebReadableStream } from 'node:stream/web'
10+
11+
/**
12+
* Normalize `fetch` response body to a Node.js readable stream.
13+
*/
14+
export function bodyToNodeReadable(body: unknown): NodeJS.ReadableStream {
15+
if (body == null)
16+
throw new Error('Response has no body')
17+
if (typeof (body as WebReadableStream).getReader === 'function')
18+
return Readable.fromWeb(body as WebReadableStream)
19+
return body as NodeJS.ReadableStream
20+
}
21+
22+
/**
23+
* Append only user-visible completion tokens from `delta.content`.
24+
* Skips reasoning / `reasoning_content` (not present on `content` in typical deltas).
25+
*/
26+
export function appendVisibleDelta(acc: string, delta: { content?: unknown } | undefined): string {
27+
if (!delta)
28+
return acc
29+
const c = delta.content
30+
if (c == null)
31+
return acc
32+
if (typeof c === 'string')
33+
return acc + c
34+
if (Array.isArray(c)) {
35+
let s = acc
36+
for (const p of c) {
37+
if (p && typeof p === 'object' && (p as { type?: string, text?: string }).type === 'text') {
38+
const t = (p as { text?: string }).text
39+
if (typeof t === 'string')
40+
s += t
41+
}
42+
}
43+
return s
44+
}
45+
return acc
46+
}
47+
48+
interface StreamChoiceChunk { index?: number, delta?: { content?: unknown } }
49+
50+
interface NonStreamChoice { index?: number, message?: { content?: unknown } }
51+
52+
async function readableToUtf8String(stream: NodeJS.ReadableStream): Promise<string> {
53+
const chunks: Buffer[] = []
54+
for await (const chunk of stream as AsyncIterable<string | Buffer>) {
55+
if (Buffer.isBuffer(chunk))
56+
chunks.push(chunk)
57+
else if (typeof chunk === 'string')
58+
chunks.push(Buffer.from(chunk))
59+
else
60+
chunks.push(Buffer.from(String(chunk)))
61+
}
62+
return Buffer.concat(chunks as readonly Uint8Array[]).toString('utf8')
63+
}
64+
65+
/**
66+
* Parse a non-streaming `chat/completions` JSON body when `stream: true` was ignored.
67+
* @returns subjects slice, or `undefined` if the body is not a usable completion object.
68+
*/
69+
function trySubjectsFromNonStreamCompletionJson(
70+
body: string,
71+
choiceCount: number,
72+
): string[] | undefined {
73+
const t = body.trim()
74+
if (!t.startsWith('{'))
75+
return undefined
76+
let json: unknown
77+
try {
78+
json = JSON.parse(t)
79+
}
80+
catch {
81+
return undefined
82+
}
83+
if (!json || typeof json !== 'object')
84+
return undefined
85+
const o = json as { choices?: NonStreamChoice[], error?: { message?: string } }
86+
if (o.error)
87+
throw new Error(o.error.message || 'OpenAI API error')
88+
if (!Array.isArray(o.choices))
89+
return undefined
90+
91+
const buffers = Array.from({ length: choiceCount }, () => '')
92+
let maxIndexSeen = -1
93+
for (const ch of o.choices) {
94+
const idx = typeof ch.index === 'number' ? ch.index : 0
95+
if (idx >= 0 && idx < choiceCount) {
96+
buffers[idx] = appendVisibleDelta('', { content: ch.message?.content })
97+
maxIndexSeen = Math.max(maxIndexSeen, idx)
98+
}
99+
}
100+
if (maxIndexSeen < 0)
101+
return undefined
102+
return buffers.slice(0, maxIndexSeen + 1)
103+
}
104+
105+
function collectSubjectsFromSseLines(
106+
body: string,
107+
choiceCount: number,
108+
): { buffers: string[], maxIndexSeen: number } {
109+
const buffers = Array.from({ length: choiceCount }, () => '')
110+
let maxIndexSeen = -1
111+
for (const line of body.split(/\r?\n/)) {
112+
const trimmed = line.trim()
113+
if (!trimmed.startsWith('data:'))
114+
continue
115+
const payload = trimmed.slice(5).trim()
116+
if (payload === '[DONE]')
117+
continue
118+
try {
119+
const json = JSON.parse(payload) as {
120+
error?: { message?: string }
121+
choices?: StreamChoiceChunk[]
122+
}
123+
if (json.error)
124+
throw new Error(json.error.message || 'OpenAI stream error')
125+
126+
for (const ch of json.choices ?? []) {
127+
const idx = typeof ch.index === 'number' ? ch.index : 0
128+
if (idx >= 0 && idx < choiceCount) {
129+
buffers[idx] = appendVisibleDelta(buffers[idx], ch.delta)
130+
maxIndexSeen = Math.max(maxIndexSeen, idx)
131+
}
132+
}
133+
}
134+
catch (e) {
135+
if (e instanceof SyntaxError)
136+
continue
137+
throw e
138+
}
139+
}
140+
return { buffers, maxIndexSeen }
141+
}
142+
143+
/**
144+
* Read an OpenAI-style `chat/completions` response body and return one finished string per choice.
145+
* Primary path: SSE lines (`data: {...}`) with `choices[].delta`, bucketed by `choices[].index`
146+
* up to `choiceCount` (requested `n`). Returned length is `maxSeenIndex + 1` (capped by `choiceCount`),
147+
* mirroring non-stream `choices.length` when fewer parallel completions appear.
148+
* Fallback: if no choice index ever appears (e.g. provider ignores `stream: true` and returns one JSON object),
149+
* the full body is parsed as a non-streaming completion using `choices[].message.content`.
150+
*/
151+
export async function readChatCompletionStreamToSubjects(
152+
input: NodeJS.ReadableStream,
153+
choiceCount: number,
154+
): Promise<string[]> {
155+
if (choiceCount < 1)
156+
throw new Error('choiceCount must be at least 1')
157+
158+
const body = await readableToUtf8String(input)
159+
const { buffers, maxIndexSeen } = collectSubjectsFromSseLines(body, choiceCount)
160+
161+
if (maxIndexSeen >= 0)
162+
return buffers.slice(0, maxIndexSeen + 1)
163+
164+
const fromJson = trySubjectsFromNonStreamCompletionJson(body, choiceCount)
165+
if (fromJson !== undefined)
166+
return fromJson
167+
168+
throw new Error(
169+
'Chat completions response had no streamed choice deltas and is not a parseable non-streaming JSON body with choices (or choices were empty).',
170+
)
171+
}

0 commit comments

Comments
 (0)