Packages

Production adapters and pipelines for PortfolioCore. Vector stores, graph stores, embedders, Broadway pipelines, and advanced RAG strategies.

Current section

6 Versions

Jump to

Compare versions

13 files changed
+2083 additions
-53 deletions
  @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8 8 ## [Unreleased]
9 9
10 + ## [0.2.0] - 2025-12-27
11 +
12 + ### Added
13 +
14 + - Anthropic LLM adapter via claude_agent_sdk with streaming
15 + - OpenAI LLM adapter via codex_sdk with streaming
16 + - GraphRAG strategy combining vector search with graph traversal
17 + - Agentic strategy for tool-based iterative retrieval
18 + - Telemetry events for new adapters and strategies
19 +
20 + ### Dependencies
21 +
22 + - Added claude_agent_sdk ~> 0.6.10
23 + - Added codex_sdk ~> 0.4.3
24 + - Updated portfolio_core to 0.2.0
25 +
10 26 ## [0.1.1] - 2025-12-27
11 27
12 28 ### Added
  @@ -32,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32 48
33 49 - Initial release of PortfolioIndex
34 50
35 - [Unreleased]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.1.1...HEAD
51 + [Unreleased]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.2.0...HEAD
52 + [0.2.0]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.1.1...v0.2.0
36 53 [0.1.1]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.1.0...v0.1.1
37 54 [0.1.0]: https://github.com/nshkrdotcom/portfolio_index/releases/tag/v0.1.0
  @@ -22,9 +22,9 @@ Portfolio Index implements the port specifications defined in [Portfolio Core](h
22 22 - **Vector Store Adapters** - pgvector (PostgreSQL)
23 23 - **Graph Store Adapters** - Neo4j via boltx
24 24 - **Embedding Providers** - Google Gemini
25 - - **LLM Providers** - Google Gemini (gemini-2.5-flash)
25 + - **LLM Providers** - Google Gemini, Anthropic Claude, OpenAI GPT/o1
26 26 - **Broadway Pipelines** - Ingestion and embedding with backpressure
27 - - **RAG Strategies** - Hybrid (RRF fusion), Self-RAG (self-critique)
27 + - **RAG Strategies** - Hybrid (RRF fusion), Self-RAG (self-critique), GraphRAG, Agentic
28 28
29 29 ## Prerequisites
30 30
  @@ -75,7 +75,7 @@ Add `portfolio_index` to your list of dependencies in `mix.exs`:
75 75 ```elixir
76 76 def deps do
77 77 [
78 - {:portfolio_index, "~> 0.1.1"}
78 + {:portfolio_index, "~> 0.2.0"}
79 79 ]
80 80 end
81 81 ```
  @@ -230,19 +230,26 @@ config :boltx, Boltx,
230 230 |---------|---------|----------|
231 231 | `Neo4j` | Neo4j via boltx | Multi-graph isolation, Cypher queries |
232 232
233 - ### Embedder
233 + ### Embedders
234 +
235 + - **Gemini** - Google Gemini text-embedding-004
234 236
235 237 | Adapter | Provider | Model |
236 238 |---------|----------|-------|
237 239 | `Gemini` | Google | text-embedding-004 (768 dims) |
238 240 | `OpenAI` | OpenAI | text-embedding-3-small/large (placeholder) |
239 241
240 - ### LLM
242 + ### LLMs
243 +
244 + - **Gemini** - gemini-2.5-flash with streaming
245 + - **Anthropic** - Claude via claude_agent_sdk
246 + - **OpenAI** - GPT/o1 via codex_sdk
241 247
242 248 | Adapter | Provider | Model |
243 249 |---------|----------|-------|
244 250 | `Gemini` | Google | gemini-2.5-flash |
245 - | `Anthropic` | Anthropic | claude-3-sonnet (placeholder) |
251 + | `Anthropic` | Anthropic | Claude (SDK default) |
252 + | `OpenAI` | OpenAI | GPT/o1 (SDK default) |
246 253
247 254 ### Chunker
248 255
  @@ -252,12 +259,17 @@ config :boltx, Boltx,
252 259
253 260 ## RAG Strategies
254 261
262 + - **Hybrid** - Vector + keyword search with Reciprocal Rank Fusion
263 + - **SelfRAG** - Retrieval with self-critique and answer refinement
264 + - **GraphRAG** - Graph-aware retrieval
265 + - **Agentic** - Tool-based iterative retrieval
266 +
255 267 | Strategy | Description |
256 268 |----------|-------------|
257 269 | `Hybrid` | Vector + keyword search with Reciprocal Rank Fusion |
258 270 | `SelfRAG` | Retrieval with self-critique and answer refinement |
259 - | `Agentic` | Agentic retrieval (placeholder, delegates to Hybrid) |
260 - | `GraphRAG` | Graph-aware retrieval (placeholder, delegates to Hybrid) |
271 + | `Agentic` | Tool-based iterative retrieval |
272 + | `GraphRAG` | Graph-aware retrieval with vector fusion |
261 273
262 274 ## Architecture
  @@ -1,7 +1,7 @@
1 1 {<<"links">>,
2 2 [{<<"GitHub">>,<<"https://github.com/nshkrdotcom/portfolio_index">>}]}.
3 3 {<<"name">>,<<"portfolio_index">>}.
4 - {<<"version">>,<<"0.1.1">>}.
4 + {<<"version">>,<<"0.2.0">>}.
5 5 {<<"description">>,
6 6 <<"Production adapters and pipelines for PortfolioCore. Vector stores, graph stores, embedders, Broadway pipelines, and advanced RAG strategies.">>}.
7 7 {<<"elixir">>,<<"~> 1.15">>}.
  @@ -25,6 +25,7 @@
25 25 <<"lib/portfolio_index/adapters/llm">>,
26 26 <<"lib/portfolio_index/adapters/llm/anthropic.ex">>,
27 27 <<"lib/portfolio_index/adapters/llm/gemini.ex">>,
28 + <<"lib/portfolio_index/adapters/llm/open_ai.ex">>,
28 29 <<"lib/portfolio_index/adapters/vector_store">>,
29 30 <<"lib/portfolio_index/adapters/vector_store/pgvector.ex">>,
30 31 <<"lib/portfolio_index/postgrex_types.ex">>,
  @@ -48,7 +49,7 @@
48 49 [[{<<"name">>,<<"portfolio_core">>},
49 50 {<<"app">>,<<"portfolio_core">>},
50 51 {<<"optional">>,false},
51 - {<<"requirement">>,<<"~> 0.1.1">>},
52 + {<<"requirement">>,<<"~> 0.2.0">>},
52 53 {<<"repository">>,<<"hexpm">>}],
53 54 [{<<"name">>,<<"ecto_sql">>},
54 55 {<<"app">>,<<"ecto_sql">>},
  @@ -75,6 +76,16 @@
75 76 {<<"optional">>,false},
76 77 {<<"requirement">>,<<"~> 0.8.6">>},
77 78 {<<"repository">>,<<"hexpm">>}],
79 + [{<<"name">>,<<"claude_agent_sdk">>},
80 + {<<"app">>,<<"claude_agent_sdk">>},
81 + {<<"optional">>,false},
82 + {<<"requirement">>,<<"~> 0.6.10">>},
83 + {<<"repository">>,<<"hexpm">>}],
84 + [{<<"name">>,<<"codex_sdk">>},
85 + {<<"app">>,<<"codex_sdk">>},
86 + {<<"optional">>,false},
87 + {<<"requirement">>,<<"~> 0.4.3">>},
88 + {<<"repository">>,<<"hexpm">>}],
78 89 [{<<"name">>,<<"req">>},
79 90 {<<"app">>,<<"req">>},
80 91 {<<"optional">>,false},
  @@ -12,7 +12,8 @@ defmodule PortfolioIndex do
12 12 - Vector Stores: `PortfolioIndex.Adapters.VectorStore.Pgvector`
13 13 - Graph Stores: `PortfolioIndex.Adapters.GraphStore.Neo4j`
14 14 - Embedders: `PortfolioIndex.Adapters.Embedder.Gemini`
15 - - LLMs: `PortfolioIndex.Adapters.LLM.Gemini`
15 + - LLMs: `PortfolioIndex.Adapters.LLM.Gemini`, `PortfolioIndex.Adapters.LLM.Anthropic`,
16 + `PortfolioIndex.Adapters.LLM.OpenAI`
16 17 - Chunkers: `PortfolioIndex.Adapters.Chunker.Recursive`
17 18 - Document Stores: `PortfolioIndex.Adapters.DocumentStore.Postgres`
18 19
  @@ -25,6 +26,8 @@ defmodule PortfolioIndex do
25 26
26 27 - `PortfolioIndex.RAG.Strategies.Hybrid` - Vector + keyword with RRF
27 28 - `PortfolioIndex.RAG.Strategies.SelfRAG` - Self-critique and refinement
29 + - `PortfolioIndex.RAG.Strategies.GraphRAG` - Graph-aware retrieval
30 + - `PortfolioIndex.RAG.Strategies.Agentic` - Tool-based iterative retrieval
28 31
29 32 ## Quick Start
  @@ -1,34 +1,623 @@
1 1 defmodule PortfolioIndex.Adapters.LLM.Anthropic do
2 - @moduledoc """
3 - Anthropic LLM adapter placeholder.
4 -
5 - This adapter satisfies the `PortfolioCore.Ports.LLM` behaviour but
6 - does not implement actual API calls yet.
7 - """
8 -
9 2 @behaviour PortfolioCore.Ports.LLM
10 3
4 + @moduledoc """
5 + Anthropic Claude LLM adapter using claude_agent_sdk.
6 +
7 + This is a thin wrapper around the claude_agent_sdk Hex library.
8 + Uses the SDK's default model unless overridden via opts.
9 +
10 + ## Configuration
11 +
12 + config :portfolio_index, :anthropic,
13 + model: nil # Uses SDK default, or specify override
14 +
15 + ## Manifest
16 +
17 + adapters:
18 + llm:
19 + module: PortfolioIndex.Adapters.LLM.Anthropic
20 + config:
21 + model: null # Uses SDK default
22 + """
23 +
11 24 require Logger
12 25
26 + alias ClaudeAgentSDK.{ContentExtractor, Message, Options}
27 +
28 + @default_model_info %{
29 + context_window: 200_000,
30 + max_output: 4096,
31 + supports_tools: true
32 + }
33 +
13 34 @impl true
14 - def complete(_messages, _opts) do
15 - Logger.error("Anthropic LLM adapter not implemented")
16 - {:error, :not_implemented}
35 + def complete(messages, opts \\ []) do
36 + sdk = sdk_module()
37 + _ = Code.ensure_loaded?(sdk)
38 +
39 + cond do
40 + function_exported?(sdk, :complete, 2) ->
41 + complete_via_sdk(sdk, messages, opts)
42 +
43 + function_exported?(sdk, :query, 2) ->
44 + complete_via_query(sdk, messages, opts)
45 +
46 + true ->
47 + {:error, :unsupported_sdk}
48 + end
17 49 end
18 50
19 51 @impl true
20 - def stream(_messages, _opts) do
21 - Logger.error("Anthropic LLM adapter not implemented")
22 - {:error, :not_implemented}
52 + def stream(messages, opts \\ []) do
53 + sdk = sdk_module()
54 + _ = Code.ensure_loaded?(sdk)
55 +
56 + cond do
57 + function_exported?(sdk, :stream, 2) or function_exported?(sdk, :stream, 3) ->
58 + stream_from_sdk(messages, opts)
59 +
60 + sdk == ClaudeAgentSDK and function_exported?(ClaudeAgentSDK.Streaming, :start_session, 1) ->
61 + stream_via_streaming(messages, opts)
62 +
63 + function_exported?(sdk, :query, 2) ->
64 + stream_from_complete(messages, opts)
65 +
66 + true ->
67 + {:error, :stream_not_supported}
68 + end
23 69 end
24 70
25 71 @impl true
26 72 def supported_models do
27 - ["claude-3-sonnet-20240229"]
73 + sdk = sdk_module()
74 + _ = Code.ensure_loaded?(sdk)
75 +
76 + if function_exported?(sdk, :supported_models, 0) do
77 + sdk.supported_models()
78 + else
79 + [
80 + "claude-sonnet-4-20250514",
81 + "claude-opus-4-20250514",
82 + "claude-3-haiku-20240307"
83 + ]
84 + end
28 85 end
29 86
30 87 @impl true
31 - def model_info(_model) do
32 - %{context_window: 200_000, max_output: 4096, supports_tools: false}
88 + def model_info(model) do
89 + sdk = sdk_module()
90 + _ = Code.ensure_loaded?(sdk)
91 +
92 + if function_exported?(sdk, :model_info, 1) do
93 + case sdk.model_info(model) do
94 + %{} = info -> normalize_model_info(info)
95 + {:ok, info} when is_map(info) -> normalize_model_info(info)
96 + _ -> @default_model_info
97 + end
98 + else
99 + @default_model_info
100 + end
101 + end
102 +
103 + defp complete_via_sdk(sdk, messages, opts) do
104 + model = Keyword.get(opts, :model, configured_model())
105 + max_tokens = Keyword.get(opts, :max_tokens)
106 + system = Keyword.get(opts, :system)
107 +
108 + sdk_opts =
109 + []
110 + |> maybe_add(:model, model)
111 + |> maybe_add(:max_tokens, max_tokens)
112 + |> maybe_add(:system, system)
113 +
114 + converted_messages = convert_messages(messages)
115 +
116 + case sdk.complete(converted_messages, sdk_opts) do
117 + {:ok, response} ->
118 + emit_telemetry(:complete, %{model: response_model(response)})
119 + {:ok, normalize_response(response)}
120 +
121 + {:error, reason} ->
122 + Logger.error("Anthropic completion failed: #{inspect(reason)}")
123 + {:error, reason}
124 + end
125 + end
126 +
127 + defp complete_via_query(sdk, messages, opts) do
128 + {system_prompt, prompt} = extract_system_prompt(messages, opts)
129 + options = build_query_options(opts, system_prompt)
130 + prompt = ensure_prompt(prompt, system_prompt)
131 +
132 + response_messages =
133 + try do
134 + sdk.query(prompt, options) |> Enum.to_list()
135 + rescue
136 + error ->
137 + Logger.error("Anthropic query failed: #{inspect(error)}")
138 + return_error(error)
139 + end
140 +
141 + case response_messages do
142 + {:error, _} = error ->
143 + error
144 +
145 + _ ->
146 + case extract_query_response(response_messages, options) do
147 + {:ok, response} ->
148 + emit_telemetry(:complete, %{model: response.model})
149 + {:ok, response}
150 +
151 + {:error, reason} ->
152 + Logger.error("Anthropic completion failed: #{inspect(reason)}")
153 + {:error, reason}
154 + end
155 + end
156 + end
157 +
158 + defp stream_from_sdk(messages, opts) do
159 + model = Keyword.get(opts, :model, configured_model())
160 + max_tokens = Keyword.get(opts, :max_tokens)
161 + system = Keyword.get(opts, :system)
162 +
163 + sdk_opts =
164 + []
165 + |> maybe_add(:model, model)
166 + |> maybe_add(:max_tokens, max_tokens)
167 + |> maybe_add(:system, system)
168 +
169 + converted_messages = convert_messages(messages)
170 +
171 + stream_from_sdk_module(converted_messages, sdk_opts)
172 + end
173 +
174 + defp stream_via_streaming(messages, opts) do
175 + {system_prompt, prompt} = extract_system_prompt(messages, opts)
176 + options = build_query_options(opts, system_prompt)
177 + prompt = ensure_prompt(prompt, system_prompt)
178 +
179 + case ClaudeAgentSDK.Streaming.start_session(options) do
180 + {:ok, session} ->
181 + stream = streaming_session_stream(session, prompt)
182 + {:ok, stream}
183 +
184 + {:error, reason} ->
185 + {:error, reason}
186 + end
187 + end
188 +
189 + defp stream_from_complete(messages, opts) do
190 + case complete(messages, opts) do
191 + {:ok, %{content: content, finish_reason: reason}} ->
192 + {:ok, response_stream(content, reason)}
193 +
194 + {:error, _} = error ->
195 + error
196 + end
197 + end
198 +
199 + defp streaming_session_stream(session, prompt) do
200 + Stream.resource(
201 + fn -> start_session_stream(session, prompt) end,
202 + &continue_session_stream/1,
203 + &close_session_stream/1
204 + )
205 + end
206 +
207 + defp start_session_stream(session, prompt) do
208 + parent = self()
209 + ref = make_ref()
210 +
211 + _pid =
212 + spawn(fn ->
213 + ClaudeAgentSDK.Streaming.send_message(session, prompt)
214 + |> Enum.each(fn event -> send(parent, {:event, ref, event}) end)
215 +
216 + send(parent, {:done, ref})
217 + end)
218 +
219 + %{session: session, ref: ref, done: false}
220 + end
221 +
222 + defp continue_session_stream(%{done: true} = state), do: {:halt, state}
223 +
224 + defp continue_session_stream(%{ref: ref} = state) do
225 + receive do
226 + {:event, ^ref, %{type: :text_delta, text: text}} ->
227 + {[%{delta: text, finish_reason: nil}], state}
228 +
229 + {:event, ^ref, %{type: :message_stop}} ->
230 + {[%{delta: "", finish_reason: :stop}], %{state | done: true}}
231 +
232 + {:event, ^ref, %{type: :error, error: reason}} ->
233 + {:halt, {:error, reason}}
234 +
235 + {:done, ^ref} ->
236 + {:halt, %{state | done: true}}
237 + after
238 + 30_000 ->
239 + {:halt, {:error, :timeout}}
240 + end
241 + end
242 +
243 + defp close_session_stream(%{session: session}) do
244 + _ = ClaudeAgentSDK.Streaming.close_session(session)
245 + :ok
246 + end
247 +
248 + defp close_session_stream(_), do: :ok
249 +
250 + defp extract_query_response(messages, %Options{} = options) do
251 + case find_assistant_error(messages) do
252 + {:error, reason} ->
253 + {:error, reason}
254 +
255 + :ok ->
256 + result_message = Enum.find(messages, &(&1.type == :result))
257 + system_message = Enum.find(messages, &(&1.type == :system))
258 +
259 + content = extract_assistant_content(messages, result_message)
260 + usage = normalize_usage(result_message)
261 + finish_reason = finish_reason(result_message)
262 + model = extract_model(system_message, result_message, options)
263 +
264 + {:ok,
265 + %{
266 + content: content,
267 + model: model,
268 + usage: usage,
269 + finish_reason: finish_reason
270 + }}
271 + end
272 + end
273 +
274 + defp extract_assistant_content(messages, result_message) do
275 + assistant_texts =
276 + messages
277 + |> Enum.filter(&(&1.type == :assistant))
278 + |> Enum.map(&ContentExtractor.extract_text/1)
279 + |> Enum.reject(&blank?/1)
280 +
281 + case assistant_texts do
282 + [] ->
283 + case result_message do
284 + %Message{} -> ContentExtractor.extract_text(result_message) || ""
285 + _ -> ""
286 + end
287 +
288 + _ ->
289 + Enum.join(assistant_texts, "\n")
290 + end
291 + end
292 +
293 + defp find_assistant_error(messages) do
294 + case Enum.find(messages, &assistant_error?/1) do
295 + %Message{data: %{error: error}} when not is_nil(error) ->
296 + {:error, error}
297 +
298 + _ ->
299 + case Enum.find(messages, &result_error?/1) do
300 + %Message{data: %{error: error}} when is_binary(error) -> {:error, error}
301 + _ -> :ok
302 + end
303 + end
304 + end
305 +
306 + defp assistant_error?(%Message{type: :assistant, data: %{error: error}})
307 + when not is_nil(error),
308 + do: true
309 +
310 + defp assistant_error?(_), do: false
311 +
312 + defp result_error?(%Message{type: :result, subtype: subtype})
313 + when subtype in [:error_max_turns, :error_during_execution],
314 + do: true
315 +
316 + defp result_error?(_), do: false
317 +
318 + defp extract_model(%Message{type: :system, data: %{model: model}}, _result_message, %Options{})
319 + when is_binary(model) and model != "" do
320 + model
321 + end
322 +
323 + defp extract_model(_system_message, _result_message, %Options{model: fallback})
324 + when is_binary(fallback),
325 + do: fallback
326 +
327 + defp extract_model(_system_message, %Message{raw: raw}, _options) when is_map(raw) do
328 + model_usage = Map.get(raw, "modelUsage") || Map.get(raw, "model_usage")
329 +
330 + case model_usage do
331 + usage when is_map(usage) and map_size(usage) > 0 ->
332 + usage
333 + |> Map.keys()
334 + |> List.first()
335 +
336 + _ ->
337 + configured_model()
338 + end
339 + end
340 +
341 + defp extract_model(_system_message, _result_message, _options), do: configured_model()
342 +
343 + defp normalize_usage(%Message{type: :result, data: %{usage: usage}}), do: normalize_usage(usage)
344 +
345 + defp normalize_usage(usage) when is_map(usage) do
346 + %{
347 + input_tokens:
348 + fetch_token_value(usage, [
349 + :input_tokens,
350 + "input_tokens",
351 + :prompt_tokens,
352 + "prompt_tokens",
353 + :inputTokens,
354 + "inputTokens"
355 + ]),
356 + output_tokens:
357 + fetch_token_value(usage, [
358 + :output_tokens,
359 + "output_tokens",
360 + :completion_tokens,
361 + "completion_tokens",
362 + :outputTokens,
363 + "outputTokens"
364 + ])
365 + }
366 + end
367 +
368 + defp normalize_usage(_), do: %{}
369 +
370 + defp fetch_token_value(usage, keys) do
371 + Enum.find_value(keys, 0, &Map.get(usage, &1))
372 + end
373 +
374 + defp finish_reason(%Message{type: :result, subtype: :success}), do: :stop
375 + defp finish_reason(%Message{type: :result, subtype: :error_max_turns}), do: :length
376 + defp finish_reason(_), do: nil
377 +
378 + defp build_query_options(opts, system_prompt) do
379 + model = Keyword.get(opts, :model, configured_model())
380 + max_thinking_tokens = Keyword.get(opts, :max_thinking_tokens)
381 +
382 + %Options{}
383 + |> maybe_put_struct(:model, model)
384 + |> maybe_put_struct(:system_prompt, system_prompt)
385 + |> maybe_put_struct(:output_format, :json)
386 + |> maybe_put_struct(:max_turns, 1)
387 + |> maybe_put_struct(:max_thinking_tokens, max_thinking_tokens)
388 + end
389 +
390 + defp extract_system_prompt(messages, opts) do
391 + system_opt = Keyword.get(opts, :system)
392 +
393 + {system_messages, rest} = Enum.split_with(messages, &system_message?/1)
394 + system_prompt = system_opt || first_system_content(system_messages)
395 + prompt = format_conversation(rest)
396 +
397 + {system_prompt, prompt}
398 + end
399 +
400 + defp system_message?(%{role: role}), do: to_string(role) == "system"
401 + defp system_message?(%{"role" => role}), do: to_string(role) == "system"
402 + defp system_message?(_), do: false
403 +
404 + defp first_system_content([]), do: nil
405 + defp first_system_content([message | _]), do: message_content(message)
406 +
407 + defp format_conversation(messages) do
408 + messages
409 + |> Enum.map(&format_message/1)
410 + |> Enum.reject(&blank?/1)
411 + |> Enum.join("\n\n")
412 + end
413 +
414 + defp format_message(message) do
415 + role = message_role(message) || "user"
416 + content = message_content(message)
417 +
418 + if is_binary(content) and content != "" do
419 + "#{String.capitalize(to_string(role))}: #{content}"
420 + else
421 + ""
422 + end
423 + end
424 +
425 + defp message_role(%{role: role}), do: role
426 + defp message_role(%{"role" => role}), do: role
427 + defp message_role(_), do: nil
428 +
429 + defp message_content(%{content: content}), do: content
430 + defp message_content(%{"content" => content}), do: content
431 + defp message_content(_), do: nil
432 +
433 + defp ensure_prompt("", system_prompt) when is_binary(system_prompt) and system_prompt != "",
434 + do: system_prompt
435 +
436 + defp ensure_prompt(prompt, _system_prompt), do: prompt
437 +
438 + defp convert_messages(messages) do
439 + Enum.map(messages, fn
440 + %{role: role, content: content} ->
441 + %{role: to_string(role), content: content}
442 +
443 + %{"role" => role, "content" => content} ->
444 + %{role: role, content: content}
445 +
446 + msg when is_map(msg) ->
447 + msg
448 + end)
449 + end
450 +
451 + defp normalize_response(response) do
452 + %{
453 + content: response_content(response),
454 + model: response_model(response),
455 + usage: response_usage(response),
456 + finish_reason: normalize_finish_reason(response_finish_reason(response))
457 + }
458 + end
459 +
460 + defp response_content(response) when is_map(response) do
461 + Map.get(response, :content) || Map.get(response, "content") || ""
462 + end
463 +
464 + defp response_model(response) when is_map(response) do
465 + Map.get(response, :model) || Map.get(response, "model")
466 + end
467 +
468 + defp response_usage(response) when is_map(response) do
469 + Map.get(response, :usage) || Map.get(response, "usage") || %{}
470 + end
471 +
472 + defp response_finish_reason(response) when is_map(response) do
473 + Map.get(response, :finish_reason) ||
474 + Map.get(response, "finish_reason") ||
475 + Map.get(response, :stop_reason) ||
476 + Map.get(response, "stop_reason")
477 + end
478 +
479 + defp normalize_finish_reason(nil), do: nil
480 + defp normalize_finish_reason(:stop), do: :stop
481 + defp normalize_finish_reason(:length), do: :length
482 + defp normalize_finish_reason(:tool_use), do: :tool_use
483 + defp normalize_finish_reason("stop"), do: :stop
484 + defp normalize_finish_reason("length"), do: :length
485 + defp normalize_finish_reason("tool_use"), do: :tool_use
486 + defp normalize_finish_reason(other) when is_atom(other), do: other
487 + defp normalize_finish_reason(_), do: nil
488 +
489 + defp normalize_model_info(info) do
490 + %{
491 + context_window: map_fetch(info, :context_window, 200_000),
492 + max_output: map_fetch(info, :max_output, 4096),
493 + supports_tools: map_fetch(info, :supports_tools, true)
494 + }
495 + end
496 +
497 + defp map_fetch(map, key, default) when is_map(map) do
498 + Map.get(map, key) ||
499 + Map.get(map, Atom.to_string(key)) ||
500 + default
501 + end
502 +
503 + defp configured_model do
504 + :portfolio_index
505 + |> Application.get_env(:anthropic, [])
506 + |> Keyword.get(:model)
507 + end
508 +
509 + defp maybe_add(opts, _key, nil), do: opts
510 + defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
511 +
512 + defp maybe_put_struct(struct, _key, nil), do: struct
513 + defp maybe_put_struct(struct, key, value), do: Map.put(struct, key, value)
514 +
515 + defp stream_from_sdk_module(messages, sdk_opts) do
516 + sdk = sdk_module()
517 +
518 + cond do
519 + function_exported?(sdk, :stream, 2) ->
520 + case sdk.stream(messages, sdk_opts) do
521 + {:ok, stream} -> {:ok, normalize_stream(stream)}
522 + {:error, _} = error -> error
523 + stream -> {:ok, normalize_stream(stream)}
524 + end
525 +
526 + function_exported?(sdk, :stream, 3) ->
527 + {:ok, callback_stream(sdk, messages, sdk_opts)}
528 +
529 + true ->
530 + {:error, :stream_not_supported}
531 + end
532 + end
533 +
534 + defp callback_stream(sdk, messages, sdk_opts) do
535 + Stream.resource(
536 + fn -> start_streaming(sdk, messages, sdk_opts) end,
537 + &continue_streaming/1,
538 + fn _ -> :ok end
539 + )
540 + end
541 +
542 + defp start_streaming(sdk, messages, sdk_opts) do
543 + parent = self()
544 + ref = make_ref()
545 +
546 + _pid =
547 + spawn(fn ->
548 + result =
549 + sdk.stream(
550 + messages,
551 + fn chunk ->
552 + send(parent, {:chunk, ref, chunk})
553 + end,
554 + sdk_opts
555 + )
556 +
557 + send(parent, {:done, ref, result})
558 + end)
559 +
560 + {:streaming, ref}
561 + end
562 +
563 + defp continue_streaming({:streaming, ref}) do
564 + receive do
565 + {:chunk, ^ref, chunk} ->
566 + {[%{delta: normalize_stream_chunk(chunk), finish_reason: nil}], {:streaming, ref}}
567 +
568 + {:done, ^ref, {:error, reason}} ->
569 + {:halt, {:error, reason}}
570 +
571 + {:done, ^ref, _} ->
572 + {[%{delta: "", finish_reason: :stop}], {:done, ref}}
573 + after
574 + 30_000 ->
575 + {:halt, {:error, :timeout}}
576 + end
577 + end
578 +
579 + defp continue_streaming({:done, _ref}) do
580 + {:halt, :done}
581 + end
582 +
583 + defp normalize_stream(stream) do
584 + Stream.map(stream, fn chunk ->
585 + case chunk do
586 + %{delta: _} = existing -> existing
587 + %{content: delta} -> %{delta: delta, finish_reason: nil}
588 + delta when is_binary(delta) -> %{delta: delta, finish_reason: nil}
589 + _ -> %{delta: "", finish_reason: nil}
590 + end
591 + end)
592 + end
593 +
594 + defp normalize_stream_chunk(%{delta: delta}), do: delta
595 + defp normalize_stream_chunk(%{content: delta}), do: delta
596 + defp normalize_stream_chunk(delta) when is_binary(delta), do: delta
597 + defp normalize_stream_chunk(_), do: ""
598 +
599 + defp response_stream(content, reason) do
600 + finish_reason = reason || :stop
601 +
602 + Stream.concat([
603 + [%{delta: content, finish_reason: nil}],
604 + [%{delta: "", finish_reason: finish_reason}]
605 + ])
606 + end
607 +
608 + defp return_error(error), do: {:error, error}
609 +
610 + defp blank?(value), do: value in [nil, ""]
611 +
612 + defp sdk_module do
613 + Application.get_env(:portfolio_index, :anthropic_sdk, ClaudeAgentSDK)
614 + end
615 +
616 + defp emit_telemetry(event, metadata) do
617 + :telemetry.execute(
618 + [:portfolio_index, :llm, :anthropic, event],
619 + %{count: 1},
620 + metadata
621 + )
33 622 end
34 623 end
Loading more files…