11import { afterAll , beforeAll , beforeEach , describe , expect , test } from "bun:test"
22import path from "path"
33import { tool , type ModelMessage } from "ai"
4- import { Cause , Effect , Exit , Stream } from "effect"
4+ import { Cause , Effect , Exit , Layer , Stream } from "effect"
5+ import { HttpClientRequest , HttpClientResponse } from "effect/unstable/http"
56import z from "zod"
6- import { makeRuntime } from "../../src/effect/run-service"
7+ import { attach , makeRuntime } from "../../src/effect/run-service"
78import { LLM } from "../../src/session/llm"
9+ import { LLMClient , RequestExecutor } from "@opencode-ai/llm/route"
810import { WithInstance } from "../../src/project/with-instance"
11+ import { Auth } from "@/auth"
12+ import { Config } from "@/config/config"
913import { Provider } from "@/provider/provider"
1014import { ProviderTransform } from "@/provider/transform"
1115import { ModelsDev } from "@/provider/models"
16+ import { Plugin } from "@/plugin"
1217import { ProviderID , ModelID } from "../../src/provider/schema"
1318import { Filesystem } from "@/util/filesystem"
1419import { tmpdir } from "../fixture/fixture"
@@ -17,6 +22,29 @@ import { MessageV2 } from "../../src/session/message-v2"
1722import { SessionID , MessageID } from "../../src/session/schema"
1823import { AppRuntime } from "../../src/effect/app-runtime"
1924
25+ const openAIConfig = ( model : ModelsDev . Provider [ "models" ] [ string ] , baseURL : string ) : Partial < Config . Info > => {
26+ const { experimental : _experimental , ...configModel } = model
27+ type ConfigModel = NonNullable < NonNullable < Config . Info [ "provider" ] > [ string ] [ "models" ] > [ string ]
28+ return {
29+ enabled_providers : [ "openai" ] ,
30+ provider : {
31+ openai : {
32+ name : "OpenAI" ,
33+ env : [ "OPENAI_API_KEY" ] ,
34+ npm : "@ai-sdk/openai" ,
35+ api : "https://api.openai.com/v1" ,
36+ models : {
37+ [ model . id ] : JSON . parse ( JSON . stringify ( configModel ) ) as ConfigModel ,
38+ } ,
39+ options : {
40+ apiKey : "test-openai-key" ,
41+ baseURL,
42+ } ,
43+ } ,
44+ } ,
45+ }
46+ }
47+
2048async function getModel ( providerID : ProviderID , modelID : ModelID ) {
2149 return AppRuntime . runPromise (
2250 Effect . gen ( function * ( ) {
@@ -32,6 +60,22 @@ async function drain(input: LLM.StreamInput) {
3260 return llm . runPromise ( ( svc ) => svc . stream ( input ) . pipe ( Stream . runDrain ) )
3361}
3462
63+ async function drainWith ( layer : Layer . Layer < LLM . Service > , input : LLM . StreamInput ) {
64+ return Effect . runPromise (
65+ attach ( LLM . Service . use ( ( svc ) => svc . stream ( input ) . pipe ( Stream . runDrain ) ) ) . pipe ( Effect . provide ( layer ) ) ,
66+ )
67+ }
68+
69+ function llmLayerWithExecutor ( executor : Layer . Layer < RequestExecutor . Service > ) {
70+ return LLM . layer . pipe (
71+ Layer . provide ( Auth . defaultLayer ) ,
72+ Layer . provide ( Config . defaultLayer ) ,
73+ Layer . provide ( Provider . defaultLayer ) ,
74+ Layer . provide ( Plugin . defaultLayer ) ,
75+ Layer . provide ( LLMClient . layer . pipe ( Layer . provide ( executor ) ) ) ,
76+ )
77+ }
78+
3579describe ( "session.llm.hasToolCalls" , ( ) => {
3680 test ( "returns false for empty messages array" , ( ) => {
3781 expect ( LLM . hasToolCalls ( [ ] ) ) . toBe ( false )
@@ -614,32 +658,7 @@ describe("session.llm.stream", () => {
614658 ]
615659 const request = waitRequest ( "/responses" , createEventResponse ( responseChunks , true ) )
616660
617- await using tmp = await tmpdir ( {
618- init : async ( dir ) => {
619- await Bun . write (
620- path . join ( dir , "opencode.json" ) ,
621- JSON . stringify ( {
622- $schema : "https://opencode.ai/config.json" ,
623- enabled_providers : [ "openai" ] ,
624- provider : {
625- openai : {
626- name : "OpenAI" ,
627- env : [ "OPENAI_API_KEY" ] ,
628- npm : "@ai-sdk/openai" ,
629- api : "https://api.openai.com/v1" ,
630- models : {
631- [ model . id ] : model ,
632- } ,
633- options : {
634- apiKey : "test-openai-key" ,
635- baseURL : `${ server . url . origin } /v1` ,
636- } ,
637- } ,
638- } ,
639- } ) ,
640- )
641- } ,
642- } )
661+ await using tmp = await tmpdir ( { config : openAIConfig ( model , `${ server . url . origin } /v1` ) } )
643662
644663 await WithInstance . provide ( {
645664 directory : tmp . path ,
@@ -726,32 +745,7 @@ describe("session.llm.stream", () => {
726745 ]
727746 const request = waitRequest ( "/responses" , createEventResponse ( chunks , true ) )
728747
729- await using tmp = await tmpdir ( {
730- init : async ( dir ) => {
731- await Bun . write (
732- path . join ( dir , "opencode.json" ) ,
733- JSON . stringify ( {
734- $schema : "https://opencode.ai/config.json" ,
735- enabled_providers : [ "openai" ] ,
736- provider : {
737- openai : {
738- name : "OpenAI" ,
739- env : [ "OPENAI_API_KEY" ] ,
740- npm : "@ai-sdk/openai" ,
741- api : "https://api.openai.com/v1" ,
742- models : {
743- [ model . id ] : model ,
744- } ,
745- options : {
746- apiKey : "test-openai-key" ,
747- baseURL : `${ server . url . origin } /v1` ,
748- } ,
749- } ,
750- } ,
751- } ) ,
752- )
753- } ,
754- } )
748+ await using tmp = await tmpdir ( { config : openAIConfig ( model , `${ server . url . origin } /v1` ) } )
755749
756750 await WithInstance . provide ( {
757751 directory : tmp . path ,
@@ -802,6 +796,115 @@ describe("session.llm.stream", () => {
802796 } )
803797 } )
804798
799+ test ( "uses injected native request executor for tool calls" , async ( ) => {
800+ const source = await loadFixture ( "openai" , "gpt-5.2" )
801+ const model = source . model
802+ const chunks = [
803+ {
804+ type : "response.output_item.added" ,
805+ item : { type : "function_call" , id : "item-injected-tool" , call_id : "call-injected-tool" , name : "lookup" } ,
806+ } ,
807+ {
808+ type : "response.function_call_arguments.delta" ,
809+ item_id : "item-injected-tool" ,
810+ delta : '{"query":"weather"}' ,
811+ } ,
812+ {
813+ type : "response.output_item.done" ,
814+ item : {
815+ type : "function_call" ,
816+ id : "item-injected-tool" ,
817+ call_id : "call-injected-tool" ,
818+ name : "lookup" ,
819+ arguments : '{"query":"weather"}' ,
820+ } ,
821+ } ,
822+ {
823+ type : "response.completed" ,
824+ response : { incomplete_details : null , usage : { input_tokens : 1 , output_tokens : 1 } } ,
825+ } ,
826+ ]
827+ let captured : Record < string , unknown > | undefined
828+ let executed : unknown
829+ const executor = Layer . succeed (
830+ RequestExecutor . Service ,
831+ RequestExecutor . Service . of ( {
832+ execute : ( request ) =>
833+ Effect . gen ( function * ( ) {
834+ const web = yield * HttpClientRequest . toWeb ( request ) . pipe ( Effect . orDie )
835+ captured = ( yield * Effect . promise ( ( ) => web . json ( ) ) ) as Record < string , unknown >
836+ return HttpClientResponse . fromWeb ( request , createEventResponse ( chunks , true ) )
837+ } ) ,
838+ } ) ,
839+ )
840+
841+ await using tmp = await tmpdir ( { config : openAIConfig ( model , "https://injected-openai.test/v1" ) } )
842+
843+ await WithInstance . provide ( {
844+ directory : tmp . path ,
845+ fn : async ( ) => {
846+ const previous = process . env . OPENCODE_LLM_RUNTIME
847+ process . env . OPENCODE_LLM_RUNTIME = "native"
848+ try {
849+ const resolved = await getModel ( ProviderID . openai , ModelID . make ( model . id ) )
850+ const sessionID = SessionID . make ( "session-test-native-injected-tool" )
851+ const agent = {
852+ name : "test" ,
853+ mode : "primary" ,
854+ options : { } ,
855+ permission : [ { permission : "*" , pattern : "*" , action : "allow" } ] ,
856+ } satisfies Agent . Info
857+
858+ await drainWith ( llmLayerWithExecutor ( executor ) , {
859+ user : {
860+ id : MessageID . make ( "msg_user-native-injected-tool" ) ,
861+ sessionID,
862+ role : "user" ,
863+ time : { created : Date . now ( ) } ,
864+ agent : agent . name ,
865+ model : { providerID : ProviderID . make ( "openai" ) , modelID : resolved . id } ,
866+ } satisfies MessageV2 . User ,
867+ sessionID,
868+ model : resolved ,
869+ agent,
870+ system : [ ] ,
871+ messages : [ { role : "user" , content : "Use lookup" } ] ,
872+ tools : {
873+ lookup : tool ( {
874+ description : "Lookup data" ,
875+ inputSchema : z . object ( { query : z . string ( ) } ) ,
876+ execute : async ( args , options ) => {
877+ executed = { args, toolCallId : options . toolCallId }
878+ return { output : "looked up" }
879+ } ,
880+ } ) ,
881+ } ,
882+ } )
883+ } finally {
884+ if ( previous === undefined ) delete process . env . OPENCODE_LLM_RUNTIME
885+ else process . env . OPENCODE_LLM_RUNTIME = previous
886+ }
887+
888+ expect ( captured ?. model ) . toBe ( model . id )
889+ expect ( captured ?. tools ) . toEqual ( [
890+ {
891+ type : "function" ,
892+ name : "lookup" ,
893+ description : "Lookup data" ,
894+ parameters : {
895+ type : "object" ,
896+ properties : { query : { type : "string" } } ,
897+ required : [ "query" ] ,
898+ additionalProperties : false ,
899+ $schema : "http://json-schema.org/draft-07/schema#" ,
900+ } ,
901+ } ,
902+ ] )
903+ expect ( executed ) . toEqual ( { args : { query : "weather" } , toolCallId : "call-injected-tool" } )
904+ } ,
905+ } )
906+ } )
907+
805908 test ( "executes OpenAI tool calls through native runtime" , async ( ) => {
806909 const server = state . server
807910 if ( ! server ) {
0 commit comments