diff --git a/CHANGELOG.md b/CHANGELOG.md index b742ffa0..c4b24eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for `aigenerate` with Anthropic API. Preset model aliases are `claudeo`, `claudes`, and `claudeh`, for Claude 3 Opus, Sonnet, and Haiku, respectively. +- Enabled the GoogleGenAI extension since `GoogleGenAI.jl` is now officially registered. You can use `aigenerate` by setting the model to `gemini` and providing the `GOOGLE_API_KEY` environment variable. ### Fixed diff --git a/Project.toml b/Project.toml index 2c178caf..0ee5e96c 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PromptingTools" uuid = "670122d1-24a8-4d70-bfce-740807c42192" authors = ["J S @svilupp and contributors"] -version = "0.16.1" +version = "0.17.0" [deps] AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" @@ -17,11 +17,13 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [weakdeps] +GoogleGenAI = "903d41d1-eaca-47dd-943b-fee3930375ab" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" [extensions] +GoogleGenAIPromptingToolsExt = ["GoogleGenAI"] MarkdownPromptingToolsExt = ["Markdown"] RAGToolsExperimentalExt = ["SparseArrays", "LinearAlgebra"] diff --git a/README.md b/README.md index ac5e554e..aae0f38c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ For more practical examples, see the `examples/` folder and the [Advanced Exampl - [Package Interface](#package-interface) - [Frequently Asked Questions](#frequently-asked-questions) - [Why OpenAI](#why-openai) + - [What if I cannot access OpenAI?](#what-if-i-cannot-access-openai) - [Data Privacy and OpenAI](#data-privacy-and-openai) - [Creating OpenAI API Key](#creating-openai-api-key) - [Setting OpenAI Spending Limits](#setting-openai-spending-limits) @@ -673,6 +674,13 @@ There will be situations not or cannot use it (eg, privacy, cost, etc.). In that Note: To get started with [Ollama.ai](https://ollama.ai/), see the [Setup Guide for Ollama](#setup-guide-for-ollama) section below. +### What if I cannot access OpenAI? + +There are many alternatives: + +- **Other APIs**: MistralAI, Anthropic, Google, Together, Fireworks, Voyager (the latter ones tend to give free credits upon joining!) +- **Locally-hosted models**: Llama.cpp/Llama.jl, Ollama, vLLM (see the examples and the corresponding docs) + ### Data Privacy and OpenAI At the time of writing, OpenAI does NOT use the API calls for training their models. diff --git a/docs/Project.toml b/docs/Project.toml index 442d6991..8268e295 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -2,6 +2,7 @@ DataFramesMeta = "1313f7d8-7da2-5740-9ea0-a2ca25f37964" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365" +GoogleGenAI = "903d41d1-eaca-47dd-943b-fee3930375ab" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" diff --git a/docs/src/examples/working_with_google_ai_studio.md b/docs/src/examples/working_with_google_ai_studio.md index a25ee0d4..9ac2804d 100644 --- a/docs/src/examples/working_with_google_ai_studio.md +++ b/docs/src/examples/working_with_google_ai_studio.md @@ -6,20 +6,12 @@ Get an API key from [here](https://ai.google.dev/). If you see a documentation p Save the API key in your environment as `GOOGLE_API_KEY`. -We'll need `GoogleGenAI.jl` package: +We'll need `GoogleGenAI` package: ````julia -using Pkg; Pkg.add(url="https://github.com/tylerjthomas9/GoogleGenAI.jl/") +using Pkg; Pkg.add("GoogleGenAI") ```` -> [!WARNING] -> This tutorial is DISABLED FOR NOW, because GoogleGenAI.jl is NOT a registered package yet and, hence, we cannot have an extension for it. -> -> If you want to use Google models, you need to install GoogleGenAI and add the following file to `[extensions]` section in Project.toml: -> `GoogleGenAIPromptingToolsExt = ["GoogleGenAI"] -> -> Save the Project.toml changes and restart Julia. You can now use GoogleGenAI models with PromptingTools as shown below. - You can now use the Gemini-1.0-Pro model like any other model in PromptingTools. We **only support `aigenerate`** at the moment. Let's import PromptingTools: diff --git a/docs/src/frequently_asked_questions.md b/docs/src/frequently_asked_questions.md index 156f5121..75cbbc21 100644 --- a/docs/src/frequently_asked_questions.md +++ b/docs/src/frequently_asked_questions.md @@ -8,6 +8,13 @@ There will be situations not or cannot use it (eg, privacy, cost, etc.). In that Note: To get started with [Ollama.ai](https://ollama.ai/), see the [Setup Guide for Ollama](#setup-guide-for-ollama) section below. +### What if I cannot access OpenAI? + +There are many alternatives: + +- **Other APIs**: MistralAI, Anthropic, Google, Together, Fireworks, Voyager (the latter ones tend to give free credits upon joining!) +- **Locally-hosted models**: Llama.cpp/Llama.jl, Ollama, vLLM (see the examples and the corresponding docs) + ## Data Privacy and OpenAI At the time of writing, OpenAI does NOT use the API calls for training their models. diff --git a/ext/GoogleGenAIPromptingToolsExt.jl b/ext/GoogleGenAIPromptingToolsExt.jl index 12d87544..c8afbb37 100644 --- a/ext/GoogleGenAIPromptingToolsExt.jl +++ b/ext/GoogleGenAIPromptingToolsExt.jl @@ -9,23 +9,9 @@ const PT = PromptingTools function PromptingTools.ggi_generate_content(prompt_schema::PT.AbstractGoogleSchema, api_key::AbstractString, model_name::AbstractString, conversation; http_kwargs, api_kwargs...) - ## Build the provider - provider = GoogleGenAI.GoogleProvider(; api_key) - url = "$(provider.base_url)/models/$model_name:generateContent?key=$(provider.api_key)" - generation_config = Dict{String, Any}() - for (key, value) in api_kwargs - generation_config[string(key)] = value - end - - body = Dict("contents" => conversation, - "generationConfig" => generation_config) - response = HTTP.post(url; headers = Dict("Content-Type" => "application/json"), - body = JSON3.write(body), http_kwargs...) - if response.status >= 200 && response.status < 300 - return GoogleGenAI._parse_response(response) - else - error("Request failed with status $(response.status): $(String(response.body))") - end + ## TODO: Ignores http_kwargs for now, needs upstream change + r = GoogleGenAI.generate_content(api_key, model_name, conversation; api_kwargs...) + return r end end # end of module diff --git a/src/llm_anthropic.jl b/src/llm_anthropic.jl index c035a552..b0f4d28b 100644 --- a/src/llm_anthropic.jl +++ b/src/llm_anthropic.jl @@ -260,3 +260,7 @@ function aiscan(prompt_schema::AbstractAnthropicSchema, prompt::ALLOWED_PROMPT_T kwargs...) error("Anthropic schema does not yet support aiscan. Please use OpenAISchema instead.") end +function aiimage(prompt_schema::AbstractAnthropicSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Anthropic schema does not yet support aiimage. Please use OpenAISchema instead.") +end diff --git a/src/llm_google.jl b/src/llm_google.jl index 078e1ab2..165e6a0e 100644 --- a/src/llm_google.jl +++ b/src/llm_google.jl @@ -20,7 +20,7 @@ function render(schema::AbstractGoogleSchema, messages_replaced = render(NoSchema(), messages; conversation, kwargs...) ## Second pass: convert to the OpenAI schema - conversation = Dict{String, Any}[] + conversation = Dict{Symbol, Any}[] # replace any handlebar variables in the messages for msg in messages_replaced @@ -32,21 +32,21 @@ function render(schema::AbstractGoogleSchema, elseif msg isa AIMessage "model" end - push!(conversation, Dict("role" => role, "parts" => [Dict("text" => msg.content)])) + push!(conversation, Dict(:role => role, :parts => [Dict("text" => msg.content)])) end ## Merge any subsequent UserMessages - merged_conversation = Dict{String, Any}[] + merged_conversation = Dict{Symbol, Any}[] # run n-1 times, look at the current item and the next one i = 1 while i <= (length(conversation) - 1) next_i = i + 1 - if conversation[i]["role"] == "user" && conversation[next_i]["role"] == "user" + if conversation[i][:role] == "user" && conversation[next_i][:role] == "user" ## Concat the user messages to together, put two newlines - txt1 = conversation[i]["parts"][1]["text"] - txt2 = conversation[next_i]["parts"][1]["text"] + txt1 = conversation[i][:parts][1]["text"] + txt2 = conversation[next_i][:parts][1]["text"] merged_text = isempty(txt1) || isempty(txt2) ? txt1 * txt2 : txt1 * "\n\n" * txt2 - new_msg = Dict("role" => "user", "parts" => [Dict("text" => merged_text)]) + new_msg = Dict(:role => "user", :parts => [Dict("text" => merged_text)]) push!(merged_conversation, new_msg) i += 2 else @@ -178,7 +178,7 @@ function aigenerate(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_ output_token_estimate = length(r.text) msg = AIMessage(; content = r.text |> strip, - status = 200, + status = convert(Int, r.response_status), ## for google it's CHARACTERS, not tokens tokens = (input_token_estimate, output_token_estimate), elapsed = time) @@ -198,3 +198,24 @@ function aigenerate(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_ return output end + +function aiembed(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Google schema does not yet support aiembed. Please use OpenAISchema instead.") +end +function aiclassify(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Google schema does not yet support aiclassify. Please use OpenAISchema instead.") +end +function aiextract(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Google schema does not yet support aiextract. Please use OpenAISchema instead.") +end +function aiscan(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Google schema does not yet support aiscan. Please use OpenAISchema instead.") +end +function aiimage(prompt_schema::AbstractGoogleSchema, prompt::ALLOWED_PROMPT_TYPE; + kwargs...) + error("Google schema does not yet support aiimage. Please use OpenAISchema instead.") +end diff --git a/src/llm_interface.jl b/src/llm_interface.jl index db299330..8e749479 100644 --- a/src/llm_interface.jl +++ b/src/llm_interface.jl @@ -246,7 +246,7 @@ struct GoogleSchema <: AbstractGoogleSchema end "Echoes the user's input back to them. Used for testing the implementation" @kwdef mutable struct TestEchoGoogleSchema <: AbstractGoogleSchema text::Any - status::Integer + response_status::Integer model_id::String = "" inputs::Any = nothing end diff --git a/test/llm_anthropic.jl b/test/llm_anthropic.jl index f3ec372e..713f5b67 100644 --- a/test/llm_anthropic.jl +++ b/test/llm_anthropic.jl @@ -153,4 +153,12 @@ end @test schema2.inputs.system == "Act as a helpful AI assistant" @test schema2.inputs.messages == [Dict("role" => "user", "content" => "Hello World")] @test schema2.model_id == "claude-3-sonnet-20240229" -end \ No newline at end of file +end + +@testset "not implemented ai* functions" begin + @test_throws ErrorException aiembed(AnthropicSchema(), "prompt") + @test_throws ErrorException aiextract(AnthropicSchema(), "prompt") + @test_throws ErrorException aiclassify(AnthropicSchema(), "prompt") + @test_throws ErrorException aiscan(AnthropicSchema(), "prompt") + @test_throws ErrorException aiimage(AnthropicSchema(), "prompt") +end diff --git a/test/llm_google.jl b/test/llm_google.jl index 51880346..971540d6 100644 --- a/test/llm_google.jl +++ b/test/llm_google.jl @@ -8,18 +8,18 @@ using PromptingTools: UserMessage, DataMessage # Given a schema and a vector of messages with handlebar variables, it should replace the variables with the correct values in the conversation dictionary. messages = [ SystemMessage("Act as a helpful AI assistant"), - UserMessage("Hello, my name is {{name}}"), + UserMessage("Hello, my name is {{name}}") ] expected_output = [ - Dict("role" => "user", - "parts" => [ - Dict("text" => "Act as a helpful AI assistant\n\nHello, my name is John"), - ]), + Dict(:role => "user", + :parts => [ + Dict("text" => "Act as a helpful AI assistant\n\nHello, my name is John") + ]) ] conversation = render(schema, messages; name = "John") @test conversation == expected_output # Test with dry_run=true on ai* functions - test_schema = TestEchoGoogleSchema(; text = "a", status = 0) + test_schema = TestEchoGoogleSchema(; text = "a", response_status = 0) @test aigenerate(test_schema, messages; name = "John", @@ -35,12 +35,12 @@ using PromptingTools: UserMessage, DataMessage # AI message does NOT replace variables messages = [ SystemMessage("Act as a helpful AI assistant"), - AIMessage("Hello, my name is {{name}}"), + AIMessage("Hello, my name is {{name}}") ] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant")]), - Dict("role" => "model", "parts" => [Dict("text" => "Hello, my name is {{name}}")]), + Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant")]), + Dict(:role => "model", :parts => [Dict("text" => "Hello, my name is {{name}}")]) ] conversation = render(schema, messages; name = "John") # Broken: AIMessage does not replace handlebar variables @@ -48,12 +48,12 @@ using PromptingTools: UserMessage, DataMessage # Given a schema and a vector of messages with no system messages, it should add a default system prompt to the conversation dictionary. messages = [ - UserMessage("User message"), + UserMessage("User message") ] conversation = render(schema, messages) expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant\n\nUser message")]), + Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant\n\nUser message")]) ] @test conversation == expected_output @@ -62,14 +62,14 @@ using PromptingTools: UserMessage, DataMessage UserMessage("Hello"), AIMessage("Hi there"), UserMessage("How are you?"), - AIMessage("I'm doing well, thank you!"), + AIMessage("I'm doing well, thank you!") ] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant\n\nHello")]), - Dict("role" => "model", "parts" => [Dict("text" => "Hi there")]), - Dict("role" => "user", "parts" => [Dict("text" => "How are you?")]), - Dict("role" => "model", "parts" => [Dict("text" => "I'm doing well, thank you!")]), + Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant\n\nHello")]), + Dict(:role => "model", :parts => [Dict("text" => "Hi there")]), + Dict(:role => "user", :parts => [Dict("text" => "How are you?")]), + Dict(:role => "model", :parts => [Dict("text" => "I'm doing well, thank you!")]) ] conversation = render(schema, messages) @test conversation == expected_output @@ -78,12 +78,12 @@ using PromptingTools: UserMessage, DataMessage messages = [ UserMessage("Hello"), AIMessage("Hi there"), - SystemMessage("This is a system message"), + SystemMessage("This is a system message") ] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "This is a system message\n\nHello")]), - Dict("role" => "model", "parts" => [Dict("text" => "Hi there")]), + Dict(:role => "user", + :parts => [Dict("text" => "This is a system message\n\nHello")]), + Dict(:role => "model", :parts => [Dict("text" => "Hi there")]) ] conversation = render(schema, messages) @test conversation == expected_output @@ -91,8 +91,8 @@ using PromptingTools: UserMessage, DataMessage # Given an empty vector of messages, it should return an empty conversation dictionary just with the system prompt messages = AbstractMessage[] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant")]), + Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant")]) ] conversation = render(schema, messages) @test conversation == expected_output @@ -100,11 +100,11 @@ using PromptingTools: UserMessage, DataMessage # Given a schema and a vector of messages with a system message containing handlebar variables not present in kwargs, it keeps the placeholder messages = [ SystemMessage("Hello, {{name}}!"), - UserMessage("How are you?"), + UserMessage("How are you?") ] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Hello, {{name}}!\n\nHow are you?")]), + Dict(:role => "user", + :parts => [Dict("text" => "Hello, {{name}}!\n\nHow are you?")]) ] conversation = render(schema, messages) # Broken because we do not remove any unused handlebar variables @@ -114,12 +114,12 @@ using PromptingTools: UserMessage, DataMessage messages = [ UserMessage("Hello"), DataMessage(; content = ones(3, 3)), - AIMessage("Hi there"), + AIMessage("Hi there") ] expected_output = [ - Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant\n\nHello")]), - Dict("role" => "model", "parts" => [Dict("text" => "Hi there")]), + Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant\n\nHello")]), + Dict(:role => "model", :parts => [Dict("text" => "Hi there")]) ] conversation = render(schema, messages) @test conversation == expected_output @@ -127,10 +127,10 @@ using PromptingTools: UserMessage, DataMessage ## Test that if either of System or User message is empty, we don't add double newlines messages = [ SystemMessage("Hello, {{name}}!"), - UserMessage(""), + UserMessage("") ] expected_output = [ - Dict("role" => "user", "parts" => [Dict("text" => "Hello, John!")]), + Dict(:role => "user", :parts => [Dict("text" => "Hello, John!")]) ] conversation = render(schema, messages; name = "John") # Broken because we do not remove any unused handlebar variables @@ -143,12 +143,12 @@ end # corresponds to GoogleGenAI v0.1.0 # Test the monkey patch - schema = TestEchoGoogleSchema(; text = "Hello!", status = 200) + schema = TestEchoGoogleSchema(; text = "Hello!", response_status = 200) msg = ggi_generate_content(schema, "", "", "Hello") @test msg isa TestEchoGoogleSchema # Real generation API - schema1 = TestEchoGoogleSchema(; text = "Hello!", status = 200) + schema1 = TestEchoGoogleSchema(; text = "Hello!", response_status = 200) msg = aigenerate(schema1, "Hello World") expected_output = AIMessage(; content = "Hello!" |> strip, @@ -156,12 +156,12 @@ end tokens = (83, 6), elapsed = msg.elapsed) @test msg == expected_output - @test schema1.inputs == Dict{String, Any}[Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant\n\nHello World")])] + @test schema1.inputs == Dict{Symbol, Any}[Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant\n\nHello World")])] @test schema1.model_id == "gemini-pro" # default model # Test different input combinations and different prompts - schema2 = TestEchoGoogleSchema(; text = "World!", status = 200) + schema2 = TestEchoGoogleSchema(; text = "World!", response_status = 200) msg = aigenerate(schema2, UserMessage("Hello {{name}}"), model = "geminixx", http_kwargs = (; verbose = 3), api_kwargs = (; temperature = 0), name = "World") @@ -171,7 +171,15 @@ end tokens = (83, 6), elapsed = msg.elapsed) @test msg == expected_output - @test schema1.inputs == Dict{String, Any}[Dict("role" => "user", - "parts" => [Dict("text" => "Act as a helpful AI assistant\n\nHello World")])] + @test schema1.inputs == Dict{Symbol, Any}[Dict(:role => "user", + :parts => [Dict("text" => "Act as a helpful AI assistant\n\nHello World")])] @test schema2.model_id == "geminixx" end + +@testset "not implemented ai* functions" begin + @test_throws ErrorException aiembed(GoogleSchema(), "prompt") + @test_throws ErrorException aiextract(GoogleSchema(), "prompt") + @test_throws ErrorException aiclassify(GoogleSchema(), "prompt") + @test_throws ErrorException aiscan(GoogleSchema(), "prompt") + @test_throws ErrorException aiimage(GoogleSchema(), "prompt") +end