|
119 | 119 | - :tool-timeout-ms - Timeout for tool calls that return a channel (default: 120000) |
120 | 120 | - :env - Environment variables map |
121 | 121 | - :github-token - GitHub token for authentication (sets COPILOT_SDK_AUTH_TOKEN env var) |
122 | | - - :use-logged-in-user? - Whether to use logged-in user auth (default: true, false when github-token provided)" |
| 122 | + - :use-logged-in-user? - Whether to use logged-in user auth (default: true, false when github-token provided) |
| 123 | + - :is-child-process? - When true, SDK is a child of an existing Copilot CLI process and uses stdio to communicate with it (no process spawning) |
| 124 | + - :on-list-models - Zero-arg fn returning a seq of model info maps; bypasses the RPC call and does not require start!" |
123 | 125 | ([] |
124 | 126 | (client {})) |
125 | 127 | ([opts] |
|
133 | 135 | {:cli-url (:cli-url opts) |
134 | 136 | :github-token (when (:github-token opts) "***") |
135 | 137 | :use-logged-in-user? (:use-logged-in-user? opts)}))) |
| 138 | + ;; Validation: is-child-process? is mutually exclusive with cli-url |
| 139 | + (when (and (:is-child-process? opts) (:cli-url opts)) |
| 140 | + (throw (ex-info "is-child-process? is mutually exclusive with cli-url" |
| 141 | + {:is-child-process? true :cli-url (:cli-url opts)}))) |
| 142 | + ;; Validation: is-child-process? requires stdio transport |
| 143 | + (when (and (:is-child-process? opts) (= false (:use-stdio? opts))) |
| 144 | + (throw (ex-info "is-child-process? requires use-stdio? to be true (or unset)" |
| 145 | + {:is-child-process? true :use-stdio? false}))) |
136 | 146 | (when-not (s/valid? ::specs/client-options opts) |
137 | 147 | (let [unknown (specs/unknown-keys opts specs/client-options-keys) |
138 | 148 | explain (s/explain-data ::specs/client-options opts) |
|
158 | 168 | (and (:github-token opts) (nil? (:use-logged-in-user? opts))) |
159 | 169 | (assoc :use-logged-in-user? false)) |
160 | 170 | merged (merge (default-options) opts-with-defaults) |
161 | | - external? (boolean (:cli-url opts)) |
162 | | - {:keys [host port]} (when (:cli-url opts) |
| 171 | + child-process? (:is-child-process? opts) |
| 172 | + cli-url? (boolean (:cli-url opts)) |
| 173 | + external? (or cli-url? child-process?) |
| 174 | + {:keys [host port]} (when cli-url? |
163 | 175 | (parse-cli-url (:cli-url opts))) |
164 | 176 | final-opts (cond-> merged |
165 | | - external? (-> (assoc :use-stdio? false) |
166 | | - (assoc :host host) |
167 | | - (assoc :port port) |
168 | | - (assoc :external-server? true)))] |
169 | | - {:options final-opts |
170 | | - :external-server? external? |
171 | | - :actual-host (or host "localhost") |
172 | | - :state (atom (assoc (initial-state port) :options final-opts))}))) |
| 177 | + cli-url? (-> (assoc :use-stdio? false) |
| 178 | + (assoc :host host) |
| 179 | + (assoc :port port) |
| 180 | + (assoc :external-server? true)) |
| 181 | + child-process? (assoc :external-server? true))] |
| 182 | + (cond-> {:options final-opts |
| 183 | + :external-server? external? |
| 184 | + :actual-host (or host "localhost") |
| 185 | + :state (atom (assoc (initial-state port) :options final-opts))} |
| 186 | + (:on-list-models opts) |
| 187 | + (assoc :on-list-models (:on-list-models opts)))))) |
173 | 188 |
|
174 | 189 | (defn state |
175 | 190 | "Get the current connection state." |
|
513 | 528 | (let [conn (proto/connect (:stdout process) (:stdin process) (:state client))] |
514 | 529 | (swap! (:state client) assoc :connection-io conn)))) |
515 | 530 |
|
| 531 | +(defn- non-closing-input-stream |
| 532 | + "Wrap an InputStream so that .close is a no-op. |
| 533 | + Prevents proto/disconnect from closing System/in." |
| 534 | + ^java.io.InputStream [^java.io.InputStream in] |
| 535 | + (proxy [java.io.FilterInputStream] [in] |
| 536 | + (close [] nil))) |
| 537 | + |
| 538 | +(defn- non-closing-output-stream |
| 539 | + "Wrap an OutputStream so that .close flushes but does not close. |
| 540 | + Prevents proto/disconnect from closing System/out while ensuring |
| 541 | + buffered bytes are sent." |
| 542 | + ^java.io.OutputStream [^java.io.OutputStream out] |
| 543 | + (proxy [java.io.FilterOutputStream] [out] |
| 544 | + (close [] (.flush ^java.io.OutputStream out)))) |
| 545 | + |
| 546 | +(defn- connect-parent-stdio! |
| 547 | + "Connect via own stdio to a parent Copilot CLI process (child process mode). |
| 548 | + Wraps System/in and System/out in non-closing wrappers so that |
| 549 | + proto/disconnect does not close the JVM's global stdio streams." |
| 550 | + [client] |
| 551 | + (swap! (:state client) assoc :connection (proto/initial-connection-state)) |
| 552 | + (let [in (non-closing-input-stream System/in) |
| 553 | + out (non-closing-output-stream System/out) |
| 554 | + conn (proto/connect in out (:state client))] |
| 555 | + (swap! (:state client) assoc :connection-io conn))) |
| 556 | + |
516 | 557 | (defn- connect-tcp! |
517 | 558 | "Connect via TCP to the CLI server." |
518 | 559 | [client] |
|
658 | 699 | (swap! (:state client) assoc :actual-port port))))) |
659 | 700 |
|
660 | 701 | ;; Connect to server |
661 | | - (if (or (:external-server? client) |
662 | | - (not (:use-stdio? (:options client)))) |
| 702 | + (cond |
| 703 | + ;; Child process mode: use own stdin/stdout to talk to parent |
| 704 | + (:is-child-process? (:options client)) |
| 705 | + (do |
| 706 | + (log/debug "Connecting via parent stdio (child process mode)") |
| 707 | + (connect-parent-stdio! client)) |
| 708 | + |
| 709 | + ;; External server (cli-url) or TCP mode |
| 710 | + (or (:external-server? client) |
| 711 | + (not (:use-stdio? (:options client)))) |
663 | 712 | (do |
664 | 713 | (log/debug "Connecting via TCP") |
665 | 714 | (connect-tcp! client)) |
| 715 | + |
| 716 | + ;; Normal stdio to spawned process |
| 717 | + :else |
666 | 718 | (do |
667 | 719 | (log/debug "Connecting via stdio") |
668 | 720 | (connect-stdio! client))) |
|
924 | 976 | "List available models with their metadata. |
925 | 977 | Results are cached per client connection to prevent rate limiting under concurrency. |
926 | 978 | Cache is cleared on stop!/force-stop!. |
927 | | - Requires authentication. |
| 979 | + When :on-list-models handler is provided in client options, calls the handler |
| 980 | + instead of the RPC method. The handler does not require a CLI connection. |
| 981 | + Requires authentication (unless :on-list-models handler is provided). |
928 | 982 | Returns a vector of model info maps with keys: |
929 | 983 | :id :name :vendor :family :version :max-input-tokens :max-output-tokens |
930 | 984 | :preview? :default-temperature :model-picker-priority |
|
939 | 993 | :supports-reasoning-effort (legacy flat key) |
940 | 994 | :vision-limits {:supported-media-types :max-prompt-images :max-prompt-image-size} (legacy)" |
941 | 995 | [client] |
942 | | - (ensure-connected! client) |
943 | | - (let [p (promise) |
944 | | - entry (swap! (:state client) update :models-cache #(or % p)) |
945 | | - cached (:models-cache entry)] |
946 | | - (cond |
947 | | - ;; Already cached result (immutable, no need to copy) |
948 | | - (vector? cached) |
949 | | - cached |
950 | | - |
951 | | - ;; We won the race and must fetch |
952 | | - (identical? cached p) |
953 | | - (try |
954 | | - (let [models (fetch-models! client)] |
955 | | - (deliver p models) |
956 | | - (swap! (:state client) assoc :models-cache models) |
957 | | - models) |
958 | | - (catch Exception e |
959 | | - (deliver p e) |
960 | | - (swap! (:state client) assoc :models-cache nil) |
961 | | - (throw e))) |
962 | | - |
963 | | - ;; Another thread is fetching, wait on promise |
964 | | - :else |
965 | | - (let [result @cached] |
966 | | - (if (instance? Exception result) |
967 | | - (throw result) |
968 | | - result))))) |
| 996 | + (let [handler (:on-list-models client)] |
| 997 | + (when-not handler (ensure-connected! client)) |
| 998 | + (let [p (promise) |
| 999 | + entry (swap! (:state client) update :models-cache #(or % p)) |
| 1000 | + cached (:models-cache entry)] |
| 1001 | + (cond |
| 1002 | + ;; Already cached result (immutable, no need to copy) |
| 1003 | + (vector? cached) |
| 1004 | + cached |
| 1005 | + |
| 1006 | + ;; We won the race and must fetch |
| 1007 | + (identical? cached p) |
| 1008 | + (try |
| 1009 | + (let [models (if handler |
| 1010 | + (vec (handler)) |
| 1011 | + (fetch-models! client))] |
| 1012 | + (deliver p models) |
| 1013 | + (swap! (:state client) assoc :models-cache models) |
| 1014 | + models) |
| 1015 | + (catch Exception e |
| 1016 | + (deliver p e) |
| 1017 | + (swap! (:state client) assoc :models-cache nil) |
| 1018 | + (throw e))) |
| 1019 | + |
| 1020 | + ;; Another thread is fetching, wait on promise |
| 1021 | + :else |
| 1022 | + (let [result @cached] |
| 1023 | + (if (instance? Exception result) |
| 1024 | + (throw result) |
| 1025 | + result)))))) |
969 | 1026 |
|
970 | 1027 | (defn list-tools |
971 | 1028 | "List available tools with their metadata. |
|
1079 | 1136 | (:working-directory config) (assoc :working-directory (:working-directory config)) |
1080 | 1137 | wire-infinite-sessions (assoc :infinite-sessions wire-infinite-sessions) |
1081 | 1138 | (:reasoning-effort config) (assoc :reasoning-effort (:reasoning-effort config)) |
| 1139 | + (:agent config) (assoc :agent (:agent config)) |
1082 | 1140 | true (assoc :request-user-input (boolean (:on-user-input-request config))) |
1083 | 1141 | true (assoc :hooks (boolean (:hooks config))) |
1084 | 1142 | true (assoc :env-value-mode "direct")))) |
|
1127 | 1185 | (:disabled-skills config) (assoc :disabled-skills (:disabled-skills config)) |
1128 | 1186 | wire-infinite-sessions (assoc :infinite-sessions wire-infinite-sessions) |
1129 | 1187 | (:reasoning-effort config) (assoc :reasoning-effort (:reasoning-effort config)) |
| 1188 | + (:agent config) (assoc :agent (:agent config)) |
1130 | 1189 | true (assoc :request-user-input (boolean (:on-user-input-request config))) |
1131 | 1190 | true (assoc :hooks (boolean (:hooks config))) |
1132 | 1191 | (:working-directory config) (assoc :working-directory (:working-directory config)) |
|
0 commit comments