1:- module(llm, [llm/2, llm/3, config/2]).
22:- use_module(library(error)). 23:- use_module(library(apply)). 24:- use_module(library(http/http_client)). 25:- use_module(library(http/http_json)). 26:- use_module(library(http/http_ssl_plugin)). 27:- use_module(library(option)). 28
29:- dynamic llm_config_url/1. 30:- dynamic llm_config_model/1.
37llm(Input, Output) :-
38 llm(Input, Output, []).
44llm(Input, Output, Options) :-
45 llm_options(Options, Model, Timeout),
46 ( var(Input)
47 -> ensure_prompt(Output, Target),
48 generate_prompt(Target, Options, Prompt),
49 Input = Prompt,
50 constrained_prompt(Target, Prompt, PromptWithConstraint),
51 llm_prompt_text(PromptWithConstraint, Model, Timeout, Text),
52 unify_text(Text, Output)
53 ; llm_prompt_text(Input, Model, Timeout, Text),
54 unify_text(Text, Output)
55 ).
56
57llm_prompt_text(Input, Model, Timeout, Text) :-
58 ensure_prompt(Input, Prompt),
59 llm_request_body(Prompt, Model, Body),
60 llm_post_json(Body, Timeout, Response),
61 llm_extract_text(Response, Text).
62
63ensure_prompt(Input, Prompt) :-
64 ( string(Input)
65 -> Prompt = Input
66 ; atom(Input)
67 -> atom_string(Input, Prompt)
68 ; is_list(Input)
69 -> string_codes(Prompt, Input)
70 ; throw(error(type_error(text, Input), _))
71 ).
72
73llm_request_body(Prompt, Model, _{model:Model, messages:[_{role:'user', content:Prompt}]}).
74
75llm_post_json(Body, Timeout, Response) :-
76 llm_endpoint(URL),
77 llm_auth_header(Header),
78 Options = [
79 request_header('Authorization'=Header),
80 accept(json),
81 timeout(Timeout),
82 json_object(dict),
83 status_error(false),
84 status_code(Status)
85 ],
86 catch(
87 http_post(URL, json(Body), Data, Options),
88 Error,
89 throw(error(llm_request_failed(Error), _))
90 ),
91 handle_status(Status, Data, Response).
92
93handle_status(Status, Data, Response) :-
94 between(200, 299, Status),
95 !,
96 Response = Data.
97handle_status(Status, Data, _) :-
98 throw(error(llm_http_error(Status, Data), _)).
99
100unify_text(Text, Output) :-
101 ( var(Output)
102 -> Output = Text
103 ; ensure_prompt(Output, Expected),
104 Expected = Text
105 ).
106
107generate_prompt(Target, Options, Prompt) :-
108 format(string(Request),
109 "Provide a single user prompt that would make you reply with the exact text \"~w\". Return only the prompt.",
110 [Target]),
111 llm_options(Options, Model, Timeout),
112 llm_prompt_text(Request, Model, Timeout, Suggestion),
113 ensure_prompt(Suggestion, Prompt).
114
115constrained_prompt(Target, Prompt, FinalPrompt) :-
116 format(string(FinalPrompt),
117 "You must answer ONLY with the exact text \"~w\" (case sensitive, no punctuation or extra words). Now respond to the following prompt:\n\n~w",
118 [Target, Prompt]).
119
120llm_endpoint(URL) :-
121 ( llm_config_url(URL), URL \= ''
122 -> true
123 ; throw(error(existence_error(configuration, llm_url), _))
124 ).
125
(Header) :-
127 ( getenv('LLM_API_KEY', Key), Key \= ''
128 -> ensure_string(Key, KeyStr),
129 format(string(Header), 'Bearer ~w', [KeyStr])
130 ; throw(error(existence_error(environment_variable, 'LLM_API_KEY'), _))
131 ).
132
133config(URL, Model) :-
134 ensure_string(URL, URLStr),
135 ensure_string(Model, ModelStr),
136 retractall(llm_config_url(_)),
137 retractall(llm_config_model(_)),
138 assertz(llm_config_url(URLStr)),
139 assertz(llm_config_model(ModelStr)).
140
141llm_options(Options, Model, Timeout) :-
142 ( is_list(Options)
143 -> option(timeout(Timeout0), Options, 60),
144 option_model(Options, Model),
145 ensure_timeout(Timeout0, Timeout)
146 ; throw(error(type_error(list, Options), _))
147 ).
148
149option_model(Options, Model) :-
150 ( option(model(Model0), Options)
151 -> ensure_string(Model0, Model)
152 ; ( llm_config_model(Configured)
153 -> Model = Configured
154 ; throw(error(existence_error(configuration, llm_model), _))
155 )
156 ).
157
158ensure_timeout(Value, Timeout) :-
159 ( number(Value)
160 -> Timeout is Value
161 ; ensure_string(Value, Text),
162 catch(number_string(Timeout, Text), _, throw(error(type_error(number, Value), _)))
163 ).
164
(Response, Output) :-
166 ( _{choices:[First|_]} :< Response
167 -> extract_choice_text(First, Output0)
168 ; ( get_dict(output, Response, Output0)
169 ; get_dict(response, Response, Output0)
170 )
171 ),
172 ensure_string(Output0, Output),
173 !.
174llm_extract_text(Response, _) :-
175 throw(error(domain_error(llm_response, Response), _)).
176
(Choice, Text) :-
178 ( get_dict(message, Choice, Message),
179 get_dict(content, Message, Content)
180 -> normalize_content(Content, Text)
181 ; get_dict(text, Choice, Text)
182 ).
183
184normalize_content(Content, Text) :-
185 ( string(Content)
186 -> Text = Content
187 ; is_list(Content)
188 -> maplist(segment_text, Content, Segments),
189 atomics_to_string(Segments, Text)
190 ; atom(Content)
191 -> atom_string(Content, Text)
192 ; throw(error(type_error(llm_content, Content), _))
193 ).
194
195segment_text(Dict, Text) :-
196 ( is_dict(Dict),
197 get_dict(type, Dict, 'text'),
198 get_dict(text, Dict, Text0)
199 -> ensure_string(Text0, Text)
200 ; ensure_string(Dict, Text)
201 ).
202
203ensure_string(Value, Text) :-
204 ( string(Value)
205 -> Text = Value
206 ; atom(Value)
207 -> atom_string(Value, Text)
208 ; is_list(Value)
209 -> string_codes(Text, Value)
210 ; throw(error(type_error(text, Value), _))
211 )
Simple LLM client
This module exposes the predicate llm/2, which posts a user prompt to an HTTP-based large language model (LLM) API and unifies the model's response with the second argument.
Configuration is split between a one-time config predicate and an API key environment variable. Optional per-call settings let you override the model name and timeout.
The library assumes an OpenAI-compatible payload/response. To target a different API adjust llm_request_body/2 or llm_extract_text/2. */