@link-assistant/agent 0.12.1 → 0.13.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -2630,6 +2630,173 @@ const AlibabaPlugin: AuthPlugin = {
2630
2630
  },
2631
2631
  };
2632
2632
 
2633
+ /**
2634
+ * Kilo Gateway constants
2635
+ * @see https://github.com/Kilo-Org/kilo/blob/main/packages/kilo-gateway/src/api/constants.ts
2636
+ */
2637
+ const KILO_API_BASE = 'https://api.kilo.ai';
2638
+ const KILO_POLL_INTERVAL_MS = 3000;
2639
+
2640
+ /**
2641
+ * Kilo Gateway Auth Plugin
2642
+ * Supports device authorization flow for Kilo Gateway
2643
+ *
2644
+ * @see https://github.com/Kilo-Org/kilo/blob/main/packages/kilo-gateway/src/auth/device-auth.ts
2645
+ */
2646
+ const KiloPlugin: AuthPlugin = {
2647
+ provider: 'kilo',
2648
+ methods: [
2649
+ {
2650
+ label: 'Kilo Gateway (Device Authorization)',
2651
+ type: 'oauth',
2652
+ async authorize() {
2653
+ // Initiate device authorization
2654
+ const initResponse = await fetch(
2655
+ `${KILO_API_BASE}/api/device-auth/codes`,
2656
+ {
2657
+ method: 'POST',
2658
+ headers: { 'Content-Type': 'application/json' },
2659
+ }
2660
+ );
2661
+
2662
+ if (!initResponse.ok) {
2663
+ if (initResponse.status === 429) {
2664
+ log.error(() => ({
2665
+ message:
2666
+ 'kilo device auth rate limited - too many pending requests',
2667
+ }));
2668
+ return {
2669
+ method: 'auto' as const,
2670
+ async callback(): Promise<AuthResult> {
2671
+ return { type: 'failed' };
2672
+ },
2673
+ };
2674
+ }
2675
+ log.error(() => ({
2676
+ message: 'kilo device auth initiation failed',
2677
+ status: initResponse.status,
2678
+ }));
2679
+ return {
2680
+ method: 'auto' as const,
2681
+ async callback(): Promise<AuthResult> {
2682
+ return { type: 'failed' };
2683
+ },
2684
+ };
2685
+ }
2686
+
2687
+ const authData = (await initResponse.json()) as {
2688
+ code: string;
2689
+ verificationUrl: string;
2690
+ expiresIn: number;
2691
+ };
2692
+
2693
+ return {
2694
+ url: authData.verificationUrl,
2695
+ instructions: `Enter code: ${authData.code}\nWaiting for authorization...`,
2696
+ method: 'auto' as const,
2697
+ async callback(): Promise<AuthResult> {
2698
+ const maxAttempts = Math.ceil(
2699
+ (authData.expiresIn * 1000) / KILO_POLL_INTERVAL_MS
2700
+ );
2701
+
2702
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2703
+ await new Promise((resolve) =>
2704
+ setTimeout(resolve, KILO_POLL_INTERVAL_MS)
2705
+ );
2706
+
2707
+ const pollResponse = await fetch(
2708
+ `${KILO_API_BASE}/api/device-auth/codes/${authData.code}`
2709
+ );
2710
+
2711
+ if (pollResponse.status === 202) {
2712
+ // Still pending
2713
+ continue;
2714
+ }
2715
+
2716
+ if (pollResponse.status === 403) {
2717
+ log.error(() => ({
2718
+ message: 'kilo device auth denied by user',
2719
+ }));
2720
+ return { type: 'failed' };
2721
+ }
2722
+
2723
+ if (pollResponse.status === 410) {
2724
+ log.error(() => ({
2725
+ message: 'kilo device auth code expired',
2726
+ }));
2727
+ return { type: 'failed' };
2728
+ }
2729
+
2730
+ if (!pollResponse.ok) {
2731
+ log.error(() => ({
2732
+ message: 'kilo device auth poll failed',
2733
+ status: pollResponse.status,
2734
+ }));
2735
+ return { type: 'failed' };
2736
+ }
2737
+
2738
+ const data = (await pollResponse.json()) as {
2739
+ status: string;
2740
+ token?: string;
2741
+ userEmail?: string;
2742
+ };
2743
+
2744
+ if (data.status === 'approved' && data.token) {
2745
+ log.info(() => ({
2746
+ message: 'kilo device auth approved',
2747
+ email: data.userEmail,
2748
+ }));
2749
+
2750
+ // Token from Kilo device auth is long-lived (1 year)
2751
+ const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000;
2752
+ return {
2753
+ type: 'success',
2754
+ provider: 'kilo',
2755
+ refresh: data.token,
2756
+ access: data.token,
2757
+ expires: Date.now() + TOKEN_EXPIRATION_MS,
2758
+ };
2759
+ }
2760
+ }
2761
+
2762
+ log.error(() => ({
2763
+ message: 'kilo device auth timed out',
2764
+ }));
2765
+ return { type: 'failed' };
2766
+ },
2767
+ };
2768
+ },
2769
+ },
2770
+ {
2771
+ label: 'API Key',
2772
+ type: 'api',
2773
+ async authorize(inputs: Record<string, string>) {
2774
+ const key = inputs['key'];
2775
+ if (!key) return { type: 'failed' };
2776
+ return {
2777
+ type: 'success',
2778
+ provider: 'kilo',
2779
+ key,
2780
+ };
2781
+ },
2782
+ },
2783
+ ],
2784
+ async loader(getAuth) {
2785
+ const auth = await getAuth();
2786
+ if (!auth) return {};
2787
+
2788
+ if (auth.type === 'api') {
2789
+ return { apiKey: auth.key };
2790
+ }
2791
+
2792
+ if (auth.type === 'oauth') {
2793
+ return { apiKey: auth.access };
2794
+ }
2795
+
2796
+ return {};
2797
+ },
2798
+ };
2799
+
2633
2800
  /**
2634
2801
  * Registry of all auth plugins
2635
2802
  */
@@ -2640,6 +2807,7 @@ const plugins: Record<string, AuthPlugin> = {
2640
2807
  google: GooglePlugin,
2641
2808
  'qwen-coder': QwenPlugin,
2642
2809
  alibaba: AlibabaPlugin,
2810
+ kilo: KiloPlugin,
2643
2811
  };
2644
2812
 
2645
2813
  /**
@@ -128,8 +128,9 @@ export const AuthLoginCommand = cmd({
128
128
  'github-copilot': 1,
129
129
  openai: 2,
130
130
  google: 3,
131
- openrouter: 4,
132
- vercel: 5,
131
+ kilo: 4,
132
+ openrouter: 5,
133
+ vercel: 6,
133
134
  };
134
135
 
135
136
  // Note: Using `select` instead of `autocomplete` because `autocomplete` is only
package/src/index.js CHANGED
@@ -172,8 +172,10 @@ async function parseModelConfig(argv) {
172
172
  modelID = modelID || 'kimi-k2.5-free';
173
173
  }
174
174
 
175
+ // Log raw and parsed values to help diagnose model routing issues (#171)
175
176
  Log.Default.info(() => ({
176
177
  message: 'using explicit provider/model',
178
+ rawModel: modelArg,
177
179
  providerID,
178
180
  modelID,
179
181
  }));
@@ -324,19 +324,21 @@ export namespace Provider {
324
324
  },
325
325
  /**
326
326
  * Kilo provider - access to 500+ AI models through Kilo Gateway
327
- * Uses OpenAI-compatible API at https://api.kilo.ai/api/gateway
327
+ * Uses OpenRouter-compatible API at https://api.kilo.ai/api/openrouter
328
328
  *
329
- * Free models available without API key (using 'public' key):
329
+ * Authentication required: run `agent auth login` and select "Kilo Gateway"
330
+ * For API key authentication, set KILO_API_KEY environment variable
331
+ *
332
+ * Free models available after authentication:
330
333
  * - GLM-5 (z-ai/glm-5) - Free limited time, flagship Z.AI model
331
334
  * - GLM 4.7 (z-ai/glm-4.7:free) - Free, agent-centric model
332
335
  * - Kimi K2.5 (moonshot/kimi-k2.5:free) - Free, agentic capabilities
333
336
  * - MiniMax M2.1 (minimax/m2.1:free) - Free, general-purpose
334
337
  * - Giga Potato (giga-potato:free) - Free evaluation model
335
338
  *
336
- * For paid models, set KILO_API_KEY environment variable
337
- *
338
339
  * @see https://kilo.ai/docs/gateway
339
340
  * @see https://kilo.ai/docs/advanced-usage/free-and-budget-models
341
+ * @see https://github.com/Kilo-Org/kilo/tree/main/packages/kilo-gateway
340
342
  */
341
343
  kilo: async (input) => {
342
344
  const hasKey = await (async () => {
@@ -345,8 +347,7 @@ export namespace Provider {
345
347
  return false;
346
348
  })();
347
349
 
348
- // For free models, we can use 'public' as the API key
349
- // For paid models, user needs to set KILO_API_KEY
350
+ // Filter to only free models when no API key
350
351
  if (!hasKey) {
351
352
  for (const [key, value] of Object.entries(input.models)) {
352
353
  // Keep only free models (cost.input === 0) when no API key
@@ -355,13 +356,44 @@ export namespace Provider {
355
356
  }
356
357
  }
357
358
 
359
+ // Build options
360
+ const options: Record<string, any> = {};
361
+
362
+ // Kilo-specific headers for the OpenRouter-compatible API
363
+ // @see https://github.com/Kilo-Org/kilo/blob/main/packages/kilo-gateway/src/headers.ts
364
+ const headers: Record<string, string> = {
365
+ 'User-Agent': 'opencode-kilo-provider',
366
+ 'X-KILOCODE-EDITORNAME': 'link-assistant-agent',
367
+ };
368
+
369
+ // Pass KILO_ORG_ID if available
370
+ if (process.env['KILO_ORG_ID']) {
371
+ headers['X-KILOCODE-ORGANIZATIONID'] = process.env['KILO_ORG_ID'];
372
+ }
373
+
374
+ options.headers = headers;
375
+
376
+ // Use auth token if available (from `agent auth login` -> Kilo device auth)
377
+ if (!hasKey) {
378
+ // Without authentication, use 'anonymous' key
379
+ // Note: The Kilo API may reject anonymous requests for completions.
380
+ // Users should authenticate via `agent auth login` for reliable access.
381
+ options.apiKey = 'anonymous';
382
+ } else {
383
+ // If we have stored auth, the loader will provide the token
384
+ const auth = await Auth.get(input.id);
385
+ if (auth) {
386
+ if (auth.type === 'api') {
387
+ options.apiKey = auth.key;
388
+ } else if (auth.type === 'oauth') {
389
+ options.apiKey = auth.access;
390
+ }
391
+ }
392
+ }
393
+
358
394
  return {
359
395
  autoload: Object.keys(input.models).length > 0,
360
- options: hasKey
361
- ? {}
362
- : {
363
- apiKey: 'public',
364
- },
396
+ options,
365
397
  };
366
398
  },
367
399
  /**
@@ -769,13 +801,14 @@ export namespace Provider {
769
801
  database['kilo'] = {
770
802
  id: 'kilo',
771
803
  name: 'Kilo Gateway',
772
- npm: '@ai-sdk/openai-compatible',
773
- api: 'https://api.kilo.ai/api/gateway',
804
+ npm: '@openrouter/ai-sdk-provider',
805
+ api: 'https://api.kilo.ai/api/openrouter',
774
806
  env: ['KILO_API_KEY'],
775
807
  models: {
776
- // GLM-5 - Flagship Z.AI model, free for limited time
808
+ // GLM-5 - Flagship Z.AI model, free tier
809
+ // Kilo API model ID: z-ai/glm-5:free (NOT z-ai/glm-5 which is paid)
777
810
  'glm-5-free': {
778
- id: 'z-ai/glm-5',
811
+ id: 'z-ai/glm-5:free',
779
812
  name: 'GLM-5 (Free)',
780
813
  release_date: '2026-02-11',
781
814
  attachment: false,
@@ -789,7 +822,7 @@ export namespace Provider {
789
822
  cache_write: 0,
790
823
  },
791
824
  limit: {
792
- context: 202752,
825
+ context: 202800,
793
826
  output: 131072,
794
827
  },
795
828
  modalities: {
@@ -798,10 +831,11 @@ export namespace Provider {
798
831
  },
799
832
  options: {},
800
833
  },
801
- // GLM 4.7 - Agent-centric model, free
802
- 'glm-4.7-free': {
803
- id: 'z-ai/glm-4.7:free',
804
- name: 'GLM 4.7 (Free)',
834
+ // GLM 4.5 Air - Free Z.AI model (replaces non-existent glm-4.7:free)
835
+ // Kilo API model ID: z-ai/glm-4.5-air:free
836
+ 'glm-4.5-air-free': {
837
+ id: 'z-ai/glm-4.5-air:free',
838
+ name: 'GLM 4.5 Air (Free)',
805
839
  release_date: '2026-01-15',
806
840
  attachment: false,
807
841
  reasoning: true,
@@ -815,7 +849,7 @@ export namespace Provider {
815
849
  },
816
850
  limit: {
817
851
  context: 131072,
818
- output: 65536,
852
+ output: 96000,
819
853
  },
820
854
  modalities: {
821
855
  input: ['text'],
@@ -823,13 +857,14 @@ export namespace Provider {
823
857
  },
824
858
  options: {},
825
859
  },
826
- // Kimi K2.5 - Agentic capabilities, free
827
- 'kimi-k2.5-free': {
828
- id: 'moonshot/kimi-k2.5:free',
829
- name: 'Kimi K2.5 (Free)',
830
- release_date: '2025-12-01',
860
+ // MiniMax M2.5 - General-purpose, free (upgraded from M2.1)
861
+ // Kilo API model ID: minimax/minimax-m2.5:free
862
+ 'minimax-m2.5-free': {
863
+ id: 'minimax/minimax-m2.5:free',
864
+ name: 'MiniMax M2.5 (Free)',
865
+ release_date: '2026-01-01',
831
866
  attachment: false,
832
- reasoning: false,
867
+ reasoning: true,
833
868
  temperature: true,
834
869
  tool_call: true,
835
870
  cost: {
@@ -839,8 +874,8 @@ export namespace Provider {
839
874
  cache_write: 0,
840
875
  },
841
876
  limit: {
842
- context: 131072,
843
- output: 65536,
877
+ context: 204800,
878
+ output: 131072,
844
879
  },
845
880
  modalities: {
846
881
  input: ['text'],
@@ -848,13 +883,14 @@ export namespace Provider {
848
883
  },
849
884
  options: {},
850
885
  },
851
- // MiniMax M2.1 - General-purpose, free
852
- 'minimax-m2.1-free': {
853
- id: 'minimax/m2.1:free',
854
- name: 'MiniMax M2.1 (Free)',
855
- release_date: '2025-11-01',
856
- attachment: false,
857
- reasoning: false,
886
+ // Giga Potato - Free evaluation model
887
+ // Kilo API model ID: giga-potato (no :free suffix)
888
+ 'giga-potato-free': {
889
+ id: 'giga-potato',
890
+ name: 'Giga Potato (Free)',
891
+ release_date: '2026-01-01',
892
+ attachment: true,
893
+ reasoning: true,
858
894
  temperature: true,
859
895
  tool_call: true,
860
896
  cost: {
@@ -864,19 +900,20 @@ export namespace Provider {
864
900
  cache_write: 0,
865
901
  },
866
902
  limit: {
867
- context: 131072,
868
- output: 65536,
903
+ context: 256000,
904
+ output: 32000,
869
905
  },
870
906
  modalities: {
871
- input: ['text'],
907
+ input: ['text', 'image'],
872
908
  output: ['text'],
873
909
  },
874
910
  options: {},
875
911
  },
876
- // Giga Potato - Free evaluation model
877
- 'giga-potato-free': {
878
- id: 'giga-potato:free',
879
- name: 'Giga Potato (Free)',
912
+ // Trinity Large Preview - Preview model from Arcee AI
913
+ // Kilo API model ID: arcee-ai/trinity-large-preview:free
914
+ 'trinity-large-preview': {
915
+ id: 'arcee-ai/trinity-large-preview:free',
916
+ name: 'Trinity Large Preview (Free)',
880
917
  release_date: '2026-01-01',
881
918
  attachment: false,
882
919
  reasoning: false,
@@ -889,8 +926,8 @@ export namespace Provider {
889
926
  cache_write: 0,
890
927
  },
891
928
  limit: {
892
- context: 65536,
893
- output: 32768,
929
+ context: 131000,
930
+ output: 65536,
894
931
  },
895
932
  modalities: {
896
933
  input: ['text'],
@@ -898,15 +935,16 @@ export namespace Provider {
898
935
  },
899
936
  options: {},
900
937
  },
901
- // Trinity Large Preview - Preview model from Arcee AI
902
- 'trinity-large-preview': {
903
- id: 'arcee/trinity-large-preview',
904
- name: 'Trinity Large Preview (Free)',
905
- release_date: '2026-01-01',
938
+ // DeepSeek R1 - Reasoning model, free
939
+ // Kilo API model ID: deepseek/deepseek-r1-0528:free
940
+ 'deepseek-r1-free': {
941
+ id: 'deepseek/deepseek-r1-0528:free',
942
+ name: 'DeepSeek R1 (Free)',
943
+ release_date: '2025-05-28',
906
944
  attachment: false,
907
- reasoning: false,
945
+ reasoning: true,
908
946
  temperature: true,
909
- tool_call: true,
947
+ tool_call: false,
910
948
  cost: {
911
949
  input: 0,
912
950
  output: 0,
@@ -914,8 +952,8 @@ export namespace Provider {
914
952
  cache_write: 0,
915
953
  },
916
954
  limit: {
917
- context: 65536,
918
- output: 32768,
955
+ context: 163840,
956
+ output: 163840,
919
957
  },
920
958
  modalities: {
921
959
  input: ['text'],
@@ -1293,9 +1331,9 @@ export namespace Provider {
1293
1331
  if (providerID === 'kilo') {
1294
1332
  priority = [
1295
1333
  'glm-5-free',
1296
- 'glm-4.7-free',
1297
- 'kimi-k2.5-free',
1298
- 'minimax-m2.1-free',
1334
+ 'glm-4.5-air-free',
1335
+ 'minimax-m2.5-free',
1336
+ 'deepseek-r1-free',
1299
1337
  'giga-potato-free',
1300
1338
  ];
1301
1339
  }
@@ -1391,10 +1429,9 @@ export namespace Provider {
1391
1429
  * Priority for free models:
1392
1430
  * 1. If model is uniquely available in one provider, use that provider
1393
1431
  * 2. If model is available in multiple providers, prioritize based on free model availability:
1394
- * - kilo: glm-5-free, glm-4.7-free, minimax-m2.1-free, giga-potato-free (unique to Kilo)
1395
- * - opencode: minimax-m2.5-free, big-pickle, gpt-5-nano (unique to OpenCode)
1396
- * - SHARED: kimi-k2.5-free (available in both)
1397
- * 3. For shared models like kimi-k2.5-free, prefer OpenCode first, then fall back to Kilo on rate limit
1432
+ * - kilo: glm-5-free, glm-4.5-air-free, minimax-m2.5-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
1433
+ * - opencode: big-pickle, gpt-5-nano (unique to OpenCode)
1434
+ * 3. For shared models, prefer OpenCode first, then fall back to Kilo on rate limit
1398
1435
  *
1399
1436
  * @param modelID - Short model name without provider prefix
1400
1437
  * @returns Provider ID that should handle this model, or undefined if not found
@@ -1408,9 +1445,11 @@ export namespace Provider {
1408
1445
  // Models unique to Kilo (GLM models from Z.AI are only free on Kilo)
1409
1446
  const kiloUniqueModels = [
1410
1447
  'glm-5-free',
1411
- 'glm-4.7-free',
1448
+ 'glm-4.5-air-free',
1449
+ 'minimax-m2.5-free',
1412
1450
  'giga-potato-free',
1413
1451
  'trinity-large-preview',
1452
+ 'deepseek-r1-free',
1414
1453
  ];
1415
1454
 
1416
1455
  // Check if it's a Kilo-unique model
@@ -1515,10 +1554,8 @@ export namespace Provider {
1515
1554
  * If user specifies "kilo/kimi-k2.5-free", no fallback will occur.
1516
1555
  */
1517
1556
  const SHARED_FREE_MODELS: Record<string, string[]> = {
1518
- // kimi-k2.5-free is available in both OpenCode and Kilo
1519
- 'kimi-k2.5-free': ['opencode', 'kilo'],
1520
- // Note: minimax-m2.1-free is Kilo only, minimax-m2.5-free is OpenCode only
1521
- // They are different model versions, not shared
1557
+ // Currently no shared models between OpenCode and Kilo providers.
1558
+ // Kilo models use different IDs than OpenCode models.
1522
1559
  };
1523
1560
 
1524
1561
  /**
@@ -1,6 +1,11 @@
1
1
  import type { ModelsDev } from '../provider/models';
2
2
  import { MessageV2 } from './message-v2';
3
- import { type StreamTextResult, type Tool as AITool, APICallError } from 'ai';
3
+ import {
4
+ type StreamTextResult,
5
+ type Tool as AITool,
6
+ APICallError,
7
+ JSONParseError,
8
+ } from 'ai';
4
9
  import { Log } from '../util/log';
5
10
  import { Identifier } from '../id/id';
6
11
  import { Session } from '.';
@@ -205,6 +210,22 @@ export namespace SessionProcessor {
205
210
  break;
206
211
  }
207
212
  case 'error':
213
+ // Skip stream parse errors (malformed SSE from gateway/provider)
214
+ // The AI SDK emits these as error events but continues the stream.
215
+ // Following OpenAI Codex pattern: log and skip bad events.
216
+ // See: https://github.com/link-assistant/agent/issues/169
217
+ if (JSONParseError.isInstance(value.error)) {
218
+ log.warn(() => ({
219
+ message:
220
+ 'skipping malformed SSE event (stream parse error)',
221
+ errorName: (value.error as Error)?.name,
222
+ errorMessage: (value.error as Error)?.message?.substring(
223
+ 0,
224
+ 200
225
+ ),
226
+ }));
227
+ continue;
228
+ }
208
229
  throw value.error;
209
230
 
210
231
  case 'start-step':
@@ -364,7 +385,7 @@ export namespace SessionProcessor {
364
385
  providerID: input.providerID,
365
386
  });
366
387
 
367
- // Check if error is retryable (APIError, SocketConnectionError, or TimeoutError)
388
+ // Check if error is retryable (APIError, SocketConnectionError, TimeoutError)
368
389
  const isRetryableAPIError =
369
390
  error?.name === 'APIError' && error.data.isRetryable;
370
391
  const isRetryableSocketError =