snow-ai 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { getOpenAiConfig, getCustomHeaders } from '../utils/apiConfig.js';
3
3
  * Fetch models from OpenAI-compatible API
4
4
  */
5
5
  async function fetchOpenAIModels(baseUrl, apiKey, customHeaders) {
6
- const url = `${baseUrl.replace(/\/$/, '')}/models`;
6
+ const url = `${baseUrl}/models`;
7
7
  const headers = {
8
8
  'Content-Type': 'application/json',
9
9
  ...customHeaders,
@@ -26,7 +26,7 @@ async function fetchOpenAIModels(baseUrl, apiKey, customHeaders) {
26
26
  */
27
27
  async function fetchGeminiModels(baseUrl, apiKey) {
28
28
  // Gemini uses API key as query parameter
29
- const url = `${baseUrl.replace(/\/$/, '')}/models?key=${apiKey}`;
29
+ const url = `${baseUrl}/models?key=${apiKey}`;
30
30
  const response = await fetch(url, {
31
31
  method: 'GET',
32
32
  headers: {
@@ -50,10 +50,9 @@ async function fetchGeminiModels(baseUrl, apiKey) {
50
50
  * Supports both Anthropic native format and OpenAI-compatible format for backward compatibility
51
51
  */
52
52
  async function fetchAnthropicModels(baseUrl, apiKey, customHeaders) {
53
- const url = `${baseUrl.replace(/\/$/, '')}/models`;
53
+ const url = `${baseUrl}/models`;
54
54
  const headers = {
55
55
  'Content-Type': 'application/json',
56
- 'anthropic-version': '2023-06-01',
57
56
  ...customHeaders,
58
57
  };
59
58
  if (apiKey) {
@@ -106,19 +105,19 @@ export async function fetchAvailableModels() {
106
105
  if (!config.apiKey) {
107
106
  throw new Error('API key is required for Gemini API');
108
107
  }
109
- models = await fetchGeminiModels(config.baseUrl.replace(/\/$/, '') + '/v1beta', config.apiKey);
108
+ models = await fetchGeminiModels(config.baseUrl.replace(/\/$/, ''), config.apiKey);
110
109
  break;
111
110
  case 'anthropic':
112
111
  if (!config.apiKey) {
113
112
  throw new Error('API key is required for Anthropic API');
114
113
  }
115
- models = await fetchAnthropicModels(config.baseUrl.replace(/\/$/, '') + '/v1', config.apiKey, customHeaders);
114
+ models = await fetchAnthropicModels(config.baseUrl.replace(/\/$/, ''), config.apiKey, customHeaders);
116
115
  break;
117
116
  case 'chat':
118
117
  case 'responses':
119
118
  default:
120
119
  // OpenAI-compatible API
121
- models = await fetchOpenAIModels(config.baseUrl, config.apiKey, customHeaders);
120
+ models = await fetchOpenAIModels(config.baseUrl.replace(/\/$/, ''), config.apiKey, customHeaders);
122
121
  break;
123
122
  }
124
123
  // Sort models alphabetically by id for better UX
@@ -1,18 +1,11 @@
1
- import type { ChatMessage, ToolCall } from './chat.js';
1
+ import type { ChatMessage, ToolCall, ChatCompletionTool, UsageInfo } from './types.js';
2
2
  export interface ResponseOptions {
3
3
  model: string;
4
4
  messages: ChatMessage[];
5
5
  stream?: boolean;
6
6
  temperature?: number;
7
7
  max_tokens?: number;
8
- tools?: Array<{
9
- type: 'function';
10
- function: {
11
- name: string;
12
- description?: string;
13
- parameters?: Record<string, any>;
14
- };
15
- }>;
8
+ tools?: ChatCompletionTool[];
16
9
  tool_choice?: 'auto' | 'none' | 'required';
17
10
  reasoning?: {
18
11
  summary?: 'auto' | 'none';
@@ -22,14 +15,6 @@ export interface ResponseOptions {
22
15
  store?: boolean;
23
16
  include?: string[];
24
17
  }
25
- export interface UsageInfo {
26
- prompt_tokens: number;
27
- completion_tokens: number;
28
- total_tokens: number;
29
- cache_creation_input_tokens?: number;
30
- cache_read_input_tokens?: number;
31
- cached_tokens?: number;
32
- }
33
18
  export interface ResponseStreamChunk {
34
19
  type: 'content' | 'tool_calls' | 'tool_call_delta' | 'reasoning_delta' | 'reasoning_started' | 'done' | 'usage';
35
20
  content?: string;
@@ -1,4 +1,3 @@
1
- import OpenAI from 'openai';
2
1
  import { getOpenAiConfig, getCustomSystemPrompt, getCustomHeaders } from '../utils/apiConfig.js';
3
2
  import { SYSTEM_PROMPT } from './systemPrompt.js';
4
3
  import { withRetryGenerator } from '../utils/retryUtils.js';
@@ -52,27 +51,24 @@ function convertToolsForResponses(tools) {
52
51
  strict: false
53
52
  }));
54
53
  }
55
- let openaiClient = null;
56
- function getOpenAIClient() {
57
- if (!openaiClient) {
54
+ let openaiConfig = null;
55
+ function getOpenAIConfig() {
56
+ if (!openaiConfig) {
58
57
  const config = getOpenAiConfig();
59
58
  if (!config.apiKey || !config.baseUrl) {
60
59
  throw new Error('OpenAI API configuration is incomplete. Please configure API settings first.');
61
60
  }
62
- // Get custom headers
63
61
  const customHeaders = getCustomHeaders();
64
- openaiClient = new OpenAI({
62
+ openaiConfig = {
65
63
  apiKey: config.apiKey,
66
- baseURL: config.baseUrl,
67
- defaultHeaders: {
68
- ...customHeaders
69
- }
70
- });
64
+ baseUrl: config.baseUrl,
65
+ customHeaders
66
+ };
71
67
  }
72
- return openaiClient;
68
+ return openaiConfig;
73
69
  }
74
70
  export function resetOpenAIClient() {
75
- openaiClient = null;
71
+ openaiConfig = null;
76
72
  }
77
73
  function convertToResponseInput(messages) {
78
74
  const customSystemPrompt = getCustomSystemPrompt();
@@ -177,11 +173,43 @@ function convertToResponseInput(messages) {
177
173
  }
178
174
  return { input: result, systemInstructions };
179
175
  }
176
+ /**
177
+ * Parse Server-Sent Events (SSE) stream
178
+ */
179
+ async function* parseSSEStream(reader) {
180
+ const decoder = new TextDecoder();
181
+ let buffer = '';
182
+ while (true) {
183
+ const { done, value } = await reader.read();
184
+ if (done)
185
+ break;
186
+ buffer += decoder.decode(value, { stream: true });
187
+ const lines = buffer.split('\n');
188
+ buffer = lines.pop() || '';
189
+ for (const line of lines) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed || trimmed.startsWith(':'))
192
+ continue;
193
+ if (trimmed === 'data: [DONE]') {
194
+ return;
195
+ }
196
+ if (trimmed.startsWith('data: ')) {
197
+ const data = trimmed.slice(6);
198
+ try {
199
+ yield JSON.parse(data);
200
+ }
201
+ catch (e) {
202
+ console.error('Failed to parse SSE data:', data);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
180
208
  /**
181
209
  * 使用 Responses API 创建流式响应(带自动工具调用)
182
210
  */
183
211
  export async function* createStreamingResponse(options, abortSignal, onRetry) {
184
- const client = getOpenAIClient();
212
+ const config = getOpenAIConfig();
185
213
  // 提取系统提示词和转换后的消息
186
214
  const { input: requestInput, systemInstructions } = convertToResponseInput(options.messages);
187
215
  // 使用重试包装生成器
@@ -198,15 +226,29 @@ export async function* createStreamingResponse(options, abortSignal, onRetry) {
198
226
  include: options.include || ['reasoning.encrypted_content'],
199
227
  prompt_cache_key: options.prompt_cache_key,
200
228
  };
201
- const stream = await client.responses.create(requestPayload, {
202
- signal: abortSignal,
229
+ const response = await fetch(`${config.baseUrl}/responses`, {
230
+ method: 'POST',
231
+ headers: {
232
+ 'Content-Type': 'application/json',
233
+ 'Authorization': `Bearer ${config.apiKey}`,
234
+ ...config.customHeaders
235
+ },
236
+ body: JSON.stringify(requestPayload),
237
+ signal: abortSignal
203
238
  });
239
+ if (!response.ok) {
240
+ const errorText = await response.text();
241
+ throw new Error(`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`);
242
+ }
243
+ if (!response.body) {
244
+ throw new Error('No response body from OpenAI Responses API');
245
+ }
204
246
  let contentBuffer = '';
205
247
  let toolCallsBuffer = {};
206
248
  let hasToolCalls = false;
207
249
  let currentFunctionCallId = null;
208
250
  let usageData;
209
- for await (const chunk of stream) {
251
+ for await (const chunk of parseSSEStream(response.body.getReader())) {
210
252
  if (abortSignal?.aborted) {
211
253
  return;
212
254
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared API types for all AI providers
3
+ */
4
+ export interface ImageContent {
5
+ type: 'image';
6
+ data: string;
7
+ mimeType: string;
8
+ }
9
+ export interface ToolCall {
10
+ id: string;
11
+ type: 'function';
12
+ function: {
13
+ name: string;
14
+ arguments: string;
15
+ };
16
+ }
17
+ export interface ChatMessage {
18
+ role: 'system' | 'user' | 'assistant' | 'tool';
19
+ content: string;
20
+ tool_call_id?: string;
21
+ tool_calls?: ToolCall[];
22
+ images?: ImageContent[];
23
+ }
24
+ export interface ChatCompletionTool {
25
+ type: 'function';
26
+ function: {
27
+ name: string;
28
+ description?: string;
29
+ parameters?: Record<string, any>;
30
+ };
31
+ }
32
+ export interface UsageInfo {
33
+ prompt_tokens: number;
34
+ completion_tokens: number;
35
+ total_tokens: number;
36
+ cache_creation_input_tokens?: number;
37
+ cache_read_input_tokens?: number;
38
+ cached_tokens?: number;
39
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared API types for all AI providers
3
+ */
4
+ export {};
@@ -66,6 +66,7 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
66
66
  const [searchTerm, setSearchTerm] = useState('');
67
67
  const [manualInputMode, setManualInputMode] = useState(false);
68
68
  const [manualInputValue, setManualInputValue] = useState('');
69
+ const [, forceUpdate] = useState(0);
69
70
  const requestMethodOptions = [
70
71
  {
71
72
  label: 'Chat Completions - Modern chat API (GPT-4, GPT-3.5-turbo)',
@@ -166,6 +167,7 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
166
167
  if (value === '__DELETE__') {
167
168
  if (activeProfile === 'default') {
168
169
  setErrors(['Cannot delete the default profile']);
170
+ setIsEditing(false); // Exit editing mode to prevent Select component error
169
171
  return;
170
172
  }
171
173
  setProfileMode('deleting');
@@ -222,6 +224,10 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
222
224
  const handleDeleteProfile = () => {
223
225
  try {
224
226
  deleteProfile(activeProfile);
227
+ // Important: Update activeProfile state BEFORE loading profiles
228
+ // because deleteProfile switches to 'default' if the active profile is deleted
229
+ const newActiveProfile = getActiveProfileName();
230
+ setActiveProfile(newActiveProfile);
225
231
  loadProfilesAndConfig();
226
232
  setProfileMode('normal');
227
233
  setIsEditing(false);
@@ -387,6 +393,8 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
387
393
  key.escape) {
388
394
  setIsEditing(false);
389
395
  setSearchTerm('');
396
+ // Force re-render to clear Select component artifacts
397
+ forceUpdate(prev => prev + 1);
390
398
  return;
391
399
  }
392
400
  // Handle editing mode
@@ -615,26 +623,58 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
615
623
  activeProfile && (React.createElement(Text, { color: "cyan", dimColor: true },
616
624
  "Active Profile: ",
617
625
  activeProfile))))),
618
- React.createElement(Box, { flexDirection: "column" },
626
+ isEditing &&
627
+ (currentField === 'profile' ||
628
+ currentField === 'requestMethod' ||
629
+ currentField === 'advancedModel' ||
630
+ currentField === 'basicModel' ||
631
+ currentField === 'compactModelName') ? (React.createElement(Box, { flexDirection: "column" },
632
+ React.createElement(Text, { color: "green" },
633
+ "\u276F ",
634
+ currentField === 'profile' && 'Profile',
635
+ currentField === 'requestMethod' && 'Request Method',
636
+ currentField === 'advancedModel' && 'Advanced Model',
637
+ currentField === 'basicModel' && 'Basic Model',
638
+ currentField === 'compactModelName' && 'Compact Model',
639
+ ":"),
640
+ React.createElement(Box, { marginLeft: 3, marginTop: 1 },
641
+ currentField === 'profile' && (React.createElement(Select, { options: [
642
+ ...profiles.map(p => ({
643
+ label: `${p.displayName}${p.isActive ? ' (Active)' : ''}`,
644
+ value: p.name,
645
+ })),
646
+ {
647
+ label: chalk.green('+ New Profile'),
648
+ value: '__CREATE_NEW__',
649
+ },
650
+ {
651
+ label: chalk.red('🆇 Delete Profile'),
652
+ value: '__DELETE__',
653
+ },
654
+ ], defaultValue: activeProfile, onChange: handleProfileChange })),
655
+ currentField === 'requestMethod' && (React.createElement(Select, { options: requestMethodOptions, defaultValue: requestMethod, onChange: value => {
656
+ setRequestMethod(value);
657
+ setIsEditing(false);
658
+ } })),
659
+ (currentField === 'advancedModel' ||
660
+ currentField === 'basicModel' ||
661
+ currentField === 'compactModelName') && (React.createElement(Box, { flexDirection: "column" },
662
+ searchTerm && React.createElement(Text, { color: "cyan" },
663
+ "Filter: ",
664
+ searchTerm),
665
+ React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange })))),
666
+ React.createElement(Box, { marginTop: 1 },
667
+ React.createElement(Alert, { variant: "info" },
668
+ (currentField === 'advancedModel' ||
669
+ currentField === 'basicModel' ||
670
+ currentField === 'compactModelName') &&
671
+ 'Type to filter, ↑↓ to select, Enter to confirm, Esc to cancel',
672
+ (currentField === 'profile' || currentField === 'requestMethod') &&
673
+ '↑↓ to select, Enter to confirm, Esc to cancel')))) : (React.createElement(Box, { flexDirection: "column" },
619
674
  React.createElement(Box, { flexDirection: "column" },
620
675
  React.createElement(Text, { color: currentField === 'profile' ? 'green' : 'white' },
621
676
  currentField === 'profile' ? '❯ ' : ' ',
622
677
  "Profile:"),
623
- currentField === 'profile' && isEditing && (React.createElement(Box, { marginLeft: 3 },
624
- React.createElement(Select, { options: [
625
- ...profiles.map(p => ({
626
- label: `${p.displayName}${p.isActive ? ' (Active)' : ''}`,
627
- value: p.name,
628
- })),
629
- {
630
- label: chalk.green('+ New Profile'),
631
- value: '__CREATE_NEW__',
632
- },
633
- {
634
- label: chalk.red('🆇 Delete Profile'),
635
- value: '__DELETE__',
636
- },
637
- ], defaultValue: activeProfile, onChange: handleProfileChange }))),
638
678
  (!isEditing || currentField !== 'profile') && (React.createElement(Box, { marginLeft: 3 },
639
679
  React.createElement(Text, { color: "gray" }, profiles.find(p => p.name === activeProfile)?.displayName ||
640
680
  activeProfile)))),
@@ -658,11 +698,6 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
658
698
  React.createElement(Text, { color: currentField === 'requestMethod' ? 'green' : 'white' },
659
699
  currentField === 'requestMethod' ? '❯ ' : ' ',
660
700
  "Request Method:"),
661
- currentField === 'requestMethod' && isEditing && (React.createElement(Box, { marginLeft: 3 },
662
- React.createElement(Select, { options: requestMethodOptions, defaultValue: requestMethod, onChange: value => {
663
- setRequestMethod(value);
664
- setIsEditing(false);
665
- } }))),
666
701
  (!isEditing || currentField !== 'requestMethod') && (React.createElement(Box, { marginLeft: 3 },
667
702
  React.createElement(Text, { color: "gray" }, requestMethodOptions.find(opt => opt.value === requestMethod)
668
703
  ?.label || 'Not set')))),
@@ -678,36 +713,18 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
678
713
  React.createElement(Text, { color: currentField === 'advancedModel' ? 'green' : 'white' },
679
714
  currentField === 'advancedModel' ? '❯ ' : ' ',
680
715
  "Advanced Model:"),
681
- currentField === 'advancedModel' && isEditing && (React.createElement(Box, { marginLeft: 3 },
682
- React.createElement(Box, { flexDirection: "column" },
683
- searchTerm && React.createElement(Text, { color: "cyan" },
684
- "Filter: ",
685
- searchTerm),
686
- React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange })))),
687
716
  (!isEditing || currentField !== 'advancedModel') && (React.createElement(Box, { marginLeft: 3 },
688
717
  React.createElement(Text, { color: "gray" }, advancedModel || 'Not set')))),
689
718
  React.createElement(Box, { flexDirection: "column" },
690
719
  React.createElement(Text, { color: currentField === 'basicModel' ? 'green' : 'white' },
691
720
  currentField === 'basicModel' ? '❯ ' : ' ',
692
721
  "Basic Model:"),
693
- currentField === 'basicModel' && isEditing && (React.createElement(Box, { marginLeft: 3 },
694
- React.createElement(Box, { flexDirection: "column" },
695
- searchTerm && React.createElement(Text, { color: "cyan" },
696
- "Filter: ",
697
- searchTerm),
698
- React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange })))),
699
722
  (!isEditing || currentField !== 'basicModel') && (React.createElement(Box, { marginLeft: 3 },
700
723
  React.createElement(Text, { color: "gray" }, basicModel || 'Not set')))),
701
724
  React.createElement(Box, { flexDirection: "column" },
702
725
  React.createElement(Text, { color: currentField === 'compactModelName' ? 'green' : 'white' },
703
726
  currentField === 'compactModelName' ? '❯ ' : ' ',
704
727
  "Compact Model:"),
705
- currentField === 'compactModelName' && isEditing && (React.createElement(Box, { marginLeft: 3 },
706
- React.createElement(Box, { flexDirection: "column" },
707
- searchTerm && React.createElement(Text, { color: "cyan" },
708
- "Filter: ",
709
- searchTerm),
710
- React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange })))),
711
728
  (!isEditing || currentField !== 'compactModelName') && (React.createElement(Box, { marginLeft: 3 },
712
729
  React.createElement(Text, { color: "gray" }, compactModelName || 'Not set')))),
713
730
  React.createElement(Box, { flexDirection: "column" },
@@ -729,20 +746,21 @@ export default function ConfigScreen({ onBack, onSave, inlineMode = false, }) {
729
746
  "Enter value: ",
730
747
  maxTokens))),
731
748
  (!isEditing || currentField !== 'maxTokens') && (React.createElement(Box, { marginLeft: 3 },
732
- React.createElement(Text, { color: "gray" }, maxTokens))))),
749
+ React.createElement(Text, { color: "gray" }, maxTokens)))))),
733
750
  errors.length > 0 && (React.createElement(Box, { flexDirection: "column", marginTop: 1 },
734
751
  React.createElement(Text, { color: "red", bold: true }, "Errors:"),
735
752
  errors.map((error, index) => (React.createElement(Text, { key: index, color: "red" },
736
753
  "\u2022 ",
737
754
  error))))),
738
- React.createElement(Box, { flexDirection: "column", marginTop: 1 }, isEditing ? (React.createElement(React.Fragment, null,
739
- React.createElement(Alert, { variant: "info" },
740
- "Editing mode:",
741
- ' ',
755
+ !(isEditing &&
756
+ (currentField === 'profile' ||
757
+ currentField === 'requestMethod' ||
742
758
  currentField === 'advancedModel' ||
743
- currentField === 'basicModel' ||
744
- currentField === 'compactModelName'
745
- ? 'Type to filter, ↑↓ to select, Enter to confirm'
746
- : 'Press Enter to save and exit editing'))) : (React.createElement(React.Fragment, null,
747
- React.createElement(Alert, { variant: "info" }, "Use \u2191\u2193 to navigate, Enter to edit, M for manual input, Ctrl+S or Esc to save"))))));
759
+ currentField === 'basicModel' ||
760
+ currentField === 'compactModelName')) && (React.createElement(Box, { flexDirection: "column", marginTop: 1 }, isEditing ? (React.createElement(Alert, { variant: "info" },
761
+ "Editing mode:",
762
+ ' ',
763
+ currentField === 'maxContextTokens' || currentField === 'maxTokens'
764
+ ? 'Type to edit, Enter to save'
765
+ : 'Press Enter to save and exit editing')) : (React.createElement(Alert, { variant: "info" }, "Use \u2191\u2193 to navigate, Enter to edit, M for manual input, Ctrl+S or Esc to save"))))));
748
766
  }
@@ -92,7 +92,7 @@ export default function WelcomeScreen({ version = '1.0.0', onMenuSelect, }) {
92
92
  }, [terminalWidth, stdout]);
93
93
  return (React.createElement(Box, { flexDirection: "column", width: terminalWidth },
94
94
  React.createElement(Static, { key: remountKey, items: [
95
- React.createElement(Box, { key: "welcome-header", flexDirection: "row", paddingLeft: 2, paddingTop: 1, paddingBottom: 1, width: terminalWidth },
95
+ React.createElement(Box, { key: "welcome-header", flexDirection: "row", paddingLeft: 2, paddingTop: 1, paddingBottom: 0, width: terminalWidth },
96
96
  React.createElement(Box, { flexDirection: "column", justifyContent: "center" },
97
97
  React.createElement(Text, { bold: true },
98
98
  React.createElement(Gradient, { name: "rainbow" }, "\u2746 SNOW AI CLI")),
@@ -1,4 +1,4 @@
1
- import type { ChatMessage } from '../api/chat.js';
1
+ import type { ChatMessage } from '../api/types.js';
2
2
  export interface CompressionResult {
3
3
  summary: string;
4
4
  usage: {